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#[cfg(test)]
31mod hardening_tests;
32
33/// Default per-tile byte budget `T` (SPEC.md §7.1).
34pub const DEFAULT_TILE_BUDGET: usize = 64 * 1024;
35
36/// Build the encoded pyramid-meta section for a graph: cluster, pick a round
37/// sized to `budget`, then emit the **summary** (quotient) graph. Returns
38/// `(encoded_meta, pyramid_levels)`.
39///
40/// Per-community tiles are *not* stored: they would duplicate every triple, and
41/// the exact ranged single-pattern path now routes into one permutation section
42/// without that fourth copy. Physical community-tile directories are the next
43/// storage step (SPEC §7.2).
44pub fn build_pyramid_meta(
45    dict: &Dictionary,
46    triples: &[(u32, u32, u32)],
47    budget: usize,
48) -> (Vec<u8>, u16) {
49    build_pyramid_meta_with(dict, triples, budget, None)
50}
51
52/// Like [`build_pyramid_meta`], but `type_override` forces the schema-pyramid's
53/// type predicate (e.g. `wdt:P31`) instead of auto-detection. Uses the default
54/// [`PyramidAlgo::Louvain`] community algorithm — byte-identical to before.
55pub fn build_pyramid_meta_with(
56    dict: &Dictionary,
57    triples: &[(u32, u32, u32)],
58    budget: usize,
59    type_override: Option<&str>,
60) -> (Vec<u8>, u16) {
61    build_pyramid_meta_algo(dict, triples, budget, type_override, PyramidAlgo::Louvain)
62}
63
64/// Like [`build_pyramid_meta_with`], but selects the community [`PyramidAlgo`].
65/// [`PyramidAlgo::Types`] partitions by `rdf:type` — the deterministic,
66/// parallelizable alternative to Louvain (one linear pass, no modularity) that
67/// still emits the full summary + `query_stats`; it falls back to Louvain when the
68/// graph has no usable typing. Everything downstream of the dendrogram (round
69/// choice, summary, schema pyramid, planner stats) is shared across algorithms.
70pub fn build_pyramid_meta_algo(
71    dict: &Dictionary,
72    triples: &[(u32, u32, u32)],
73    budget: usize,
74    type_override: Option<&str>,
75    algo: PyramidAlgo,
76) -> (Vec<u8>, u16) {
77    build_pyramid_meta_layout(
78        dict,
79        triples,
80        budget,
81        type_override,
82        algo,
83        PyramidLayout::Combined,
84    )
85}
86
87/// What a pyramid-meta section carries besides the community summary.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
89pub enum PyramidLayout {
90    /// Everything, as every build up to 0.3.x wrote it: the summary, the
91    /// per-community descriptors, the schema pyramid, `query_stats`, the
92    /// characteristic sets and the bounded (8,192-entry) label block.
93    #[default]
94    Combined,
95    /// Only what needs the community partition: the summary and the
96    /// per-community descriptors. The schema, `query_stats` and the
97    /// characteristic sets live in the SCHEMA section (kind 9) and the labels
98    /// in the LABEL_INDEX section (kind 8) — what `rete build` writes.
99    CommunityOnly,
100}
101
102/// Like [`build_pyramid_meta_algo`], choosing the [`PyramidLayout`].
103pub fn build_pyramid_meta_layout(
104    dict: &Dictionary,
105    triples: &[(u32, u32, u32)],
106    budget: usize,
107    type_override: Option<&str>,
108    algo: PyramidAlgo,
109    layout: PyramidLayout,
110) -> (Vec<u8>, u16) {
111    // Optional sub-phase timing (set RETE_BUILD_TIMING=1) — the pyramid build is
112    // the dominant cost of a big `rete build`; this shows where inside it.
113    // `Instant::now()` must stay behind the flag: `std::time` is unsupported on
114    // `wasm32-unknown-unknown` and panics ("time not implemented"), so an
115    // unconditional clock read would break every in-browser `build()`.
116    let timing = std::env::var_os("RETE_BUILD_TIMING").is_some();
117    let mut t = timing.then(std::time::Instant::now);
118    let mut lap = |label: &str| {
119        if let Some(t0) = &mut t {
120            eprintln!(
121                "  [pyramid] {label}: {:.0} ms",
122                t0.elapsed().as_secs_f64() * 1000.0
123            );
124            *t0 = std::time::Instant::now();
125        }
126    };
127
128    // The community partition — the only step that differs by algorithm.
129    let louvain = |lap: &mut dyn FnMut(&str)| {
130        let g = project_graph(dict, triples);
131        lap("project_graph");
132        let d = build_dendrogram(&g);
133        lap("build_dendrogram (Louvain)");
134        d
135    };
136    let dend = match algo {
137        PyramidAlgo::Louvain => louvain(&mut lap),
138        PyramidAlgo::Types => {
139            match crate::schema_pyramid::build_type_dendrogram(dict, triples, type_override) {
140                Some(d) => {
141                    lap("build_type_dendrogram");
142                    d
143                }
144                None => {
145                    eprintln!(
146                        "  [pyramid] --pyramid-algo types: no usable rdf:type \
147                         predicate — falling back to louvain"
148                    );
149                    louvain(&mut lap)
150                }
151            }
152        }
153    };
154    let round = choose_round_for_budget(dict, triples, &dend, budget);
155    lap("choose_round_for_budget");
156    let summary = summarize(dict, triples, &dend, round);
157    lap("summarize");
158    // Attach the v2 schema pyramid (the non-exclusive subClassOf DAG + per-level
159    // type rollups + per-level lateral class relations + per-community
160    // descriptors). Empty when the graph has no usable typing, in which case the
161    // encoding stays byte-identical to a v1 pyramid-meta.
162    if layout == PyramidLayout::CommunityOnly {
163        // Descriptors only: the schema pyramid's other fields, the planner
164        // stats, the shapes and the labels have sections of their own.
165        let descriptors = crate::schema_pyramid::build_community_descriptors(
166            dict,
167            triples,
168            &dend,
169            round,
170            type_override,
171        );
172        lap("build_community_descriptors");
173        let meta = PyramidMeta::new(round as u32, summary, &[]).with_schema(
174            Vec::new(),
175            Vec::new(),
176            Vec::new(),
177            descriptors,
178            Vec::new(),
179            Vec::new(),
180            Vec::new(),
181        );
182        let out = (meta.encode(), dend.rounds() as u16);
183        lap("encode");
184        return out;
185    }
186    let sp = crate::schema_pyramid::build_schema_pyramid_with(
187        dict,
188        triples,
189        &dend,
190        round,
191        type_override,
192    );
193    lap("build_schema_pyramid");
194    let predicate_stats = compute_predicate_stats(triples);
195    lap("compute_predicate_stats");
196    let char_sets = compute_char_sets(triples);
197    lap("compute_char_sets");
198    let label_index = compute_label_index(dict, triples);
199    lap("compute_label_index");
200    let meta = PyramidMeta::new(round as u32, summary, &[])
201        .with_schema(
202            sp.class_hierarchy,
203            sp.level_rollups,
204            sp.level_links,
205            sp.descriptors,
206            sp.subclass_cycles,
207            sp.disjoint_pairs,
208            sp.equivalent_pairs,
209        )
210        .with_predicate_stats(predicate_stats)
211        .with_char_sets(char_sets)
212        .with_label_index(label_index);
213    let out = (meta.encode(), dend.rounds() as u16);
214    lap("encode");
215    out
216}
217
218/// The label predicates a [`compute_label_index`] entry can come from — the
219/// common "human-readable name of this subject" terms, angle-bracketed as the
220/// dictionary stores them. Order is irrelevant (we union their ids). The same
221/// list the LABEL_INDEX section's rule L1 uses.
222const LABEL_PREDICATES: &[&str] = crate::label_index::DIRECT_LABEL_PREDICATES;
223
224/// Build the bounded, **legacy** label block of a [`PyramidLayout::Combined`]
225/// pyramid-meta (the LABEL_INDEX section, kind 8, replaces it): the display labels of
226/// the most-connected labeled subjects, sorted by the label's lowercased form.
227/// Ranking keeps autocomplete useful on a huge graph (the prominent entities
228/// survive the bound); a graph with fewer than `MAX_LABELS` labels keeps them
229/// all. Deterministic (degree, then subject id, then label) so builds are
230/// reproducible. O(triples) transient memory, freed before the file is written.
231fn compute_label_index(
232    dict: &Dictionary,
233    triples: &[(u32, u32, u32)],
234) -> Vec<crate::meta::LabelEntry> {
235    use crate::terms::{is_literal, literal_lexical};
236    use std::collections::{HashMap, HashSet};
237    const MAX_LABELS: usize = 8192;
238
239    // Resolve the label predicates that actually occur in this graph.
240    let label_pids: HashSet<u32> = LABEL_PREDICATES
241        .iter()
242        .filter_map(|p| dict.predicate_id(p))
243        .collect();
244    if label_pids.is_empty() {
245        return Vec::new();
246    }
247    // Subject degree (triple count) — the ranking used to bound the index.
248    let mut degree: HashMap<u32, u32> = HashMap::new();
249    for &(s, _p, _o) in triples {
250        *degree.entry(s).or_insert(0) += 1;
251    }
252    // Candidate (subject, label) pairs, deduped on (subject, lowercased label).
253    let mut seen: HashSet<(u32, String)> = HashSet::new();
254    let mut candidates: Vec<(u32, String, u32)> = Vec::new(); // (degree, label, subject)
255    for &(s, p, o) in triples {
256        if !label_pids.contains(&p) {
257            continue;
258        }
259        let Some(term) = dict.object_term(o) else {
260            continue;
261        };
262        if !is_literal(&term) {
263            continue;
264        }
265        let Some(label) = literal_lexical(&term) else {
266            continue;
267        };
268        if label.is_empty() {
269            continue;
270        }
271        if seen.insert((s, label.to_lowercase())) {
272            candidates.push((*degree.get(&s).unwrap_or(&0), label, s));
273        }
274    }
275    // Keep the most-connected entities when over budget: rank by degree desc,
276    // then subject asc, then label asc (deterministic).
277    if candidates.len() > MAX_LABELS {
278        candidates.sort_by(|a, b| {
279            b.0.cmp(&a.0)
280                .then_with(|| a.2.cmp(&b.2))
281                .then_with(|| a.1.cmp(&b.1))
282        });
283        candidates.truncate(MAX_LABELS);
284    }
285    // Final order: by lowercased label (search key), then label, then subject.
286    candidates.sort_by(|a, b| {
287        a.1.to_lowercase()
288            .cmp(&b.1.to_lowercase())
289            .then_with(|| a.1.cmp(&b.1))
290            .then_with(|| a.2.cmp(&b.2))
291    });
292    candidates
293        .into_iter()
294        .map(|(_deg, label, subject)| crate::meta::LabelEntry { label, subject })
295        .collect()
296}
297
298/// Build the default-scope full-text index section (`token → subjects`) over
299/// every literal object of `triples`: tokenize each literal into words and
300/// record the subject that carries it. Empty when the graph has no literals.
301/// This is the pre-0.4.0 index, byte for byte; the builds call
302/// `text_index::build_section` with their scope (`--text-index-graphs`,
303/// `--text-index-include`/`-exclude`). O(literal bytes) transient. The token
304/// table is compressed with `writer_codec` (the reader decompresses with
305/// `block_codec`).
306pub fn compute_text_index(dict: &Dictionary, triples: &[(u32, u32, u32)]) -> Vec<u8> {
307    crate::text_index::build_section(
308        dict,
309        &crate::text_index::TextIndexScope::default(),
310        triples,
311        std::iter::empty(),
312        writer_codec(),
313    )
314}
315
316/// The top entity **shapes** (characteristic sets): group subjects by the exact
317/// set of predicates they carry, keep the most common. Bounded to `MAX_CHAR_SETS`
318/// and sorted deterministically (by subject count, then predicate list) so the
319/// encoding is reproducible. O(triples) transient memory.
320fn compute_char_sets(triples: &[(u32, u32, u32)]) -> Vec<crate::meta::CharSet> {
321    use std::collections::{BTreeSet, HashMap};
322    const MAX_CHAR_SETS: usize = 128;
323    let mut by_subject: HashMap<u32, BTreeSet<u32>> = HashMap::new();
324    for &(s, p, _o) in triples {
325        by_subject.entry(s).or_default().insert(p);
326    }
327    let mut shapes: HashMap<Vec<u32>, u64> = HashMap::new();
328    for set in by_subject.into_values() {
329        *shapes.entry(set.into_iter().collect()).or_insert(0) += 1;
330    }
331    let mut v: Vec<crate::meta::CharSet> = shapes
332        .into_iter()
333        .map(|(predicates, subjects)| crate::meta::CharSet {
334            predicates,
335            subjects,
336        })
337        .collect();
338    v.sort_by(|a, b| {
339        b.subjects
340            .cmp(&a.subjects)
341            .then_with(|| a.predicates.cmp(&b.predicates))
342    });
343    v.truncate(MAX_CHAR_SETS);
344    v
345}
346
347/// Per-predicate cardinality for the cost-based planner, in one pass over the
348/// triples (deduped, so a per-(subject,predicate) count is its distinct-object
349/// count). Returned sorted by predicate id for a reproducible encoding. Holds a
350/// transient `(subject -> count, object -> count)` map per predicate — O(triples)
351/// memory, freed before the file is written.
352fn compute_predicate_stats(triples: &[(u32, u32, u32)]) -> Vec<crate::meta::PredStat> {
353    use std::collections::HashMap;
354    #[allow(clippy::type_complexity)]
355    let mut acc: HashMap<u32, (HashMap<u32, u32>, HashMap<u32, u32>, u64)> = HashMap::new();
356    for &(s, p, o) in triples {
357        let e = acc.entry(p).or_default();
358        *e.0.entry(s).or_insert(0) += 1;
359        *e.1.entry(o).or_insert(0) += 1;
360        e.2 += 1;
361    }
362    let mut stats: Vec<crate::meta::PredStat> = acc
363        .into_iter()
364        .map(|(predicate, (subj, obj, count))| crate::meta::PredStat {
365            predicate,
366            count,
367            distinct_subjects: subj.len() as u64,
368            distinct_objects: obj.len() as u64,
369            max_objects_per_subject: subj.values().copied().max().unwrap_or(0),
370            max_subjects_per_object: obj.values().copied().max().unwrap_or(0),
371        })
372        .collect();
373    stats.sort_by_key(|p| p.predicate);
374    stats
375}
376
377/// No compression.
378pub const CODEC_NONE: u8 = 0;
379/// zstd compression (per section).
380pub const CODEC_ZSTD: u8 = 1;
381
382#[derive(Debug, thiserror::Error)]
383#[non_exhaustive]
384pub enum FileError {
385    #[error("header: {0}")]
386    Header(#[from] crate::header::HeaderError),
387    #[error("malformed container: {0}")]
388    Container(&'static str),
389    #[error("unknown codec: {0}")]
390    UnknownCodec(u8),
391    #[error("decompression failed: {0}")]
392    Decompress(std::io::Error),
393    #[error("io: {0}")]
394    Io(#[from] std::io::Error),
395}
396
397/// The codec the writer uses: zstd when the `compression` feature is on, else
398/// none. Reading honors whatever codec the header records (when supported).
399pub fn writer_codec() -> u8 {
400    if cfg!(feature = "compression") {
401        CODEC_ZSTD
402    } else {
403        CODEC_NONE
404    }
405}
406
407/// Intersection of two ascending-sorted, deduped id lists — the AND of two
408/// posting lists in a multi-word text search. Linear merge, output sorted.
409fn intersect_sorted(a: &[u32], b: &[u32]) -> Vec<u32> {
410    let mut out = Vec::with_capacity(a.len().min(b.len()));
411    let (mut i, mut j) = (0, 0);
412    while i < a.len() && j < b.len() {
413        match a[i].cmp(&b[j]) {
414            std::cmp::Ordering::Less => i += 1,
415            std::cmp::Ordering::Greater => j += 1,
416            std::cmp::Ordering::Equal => {
417                out.push(a[i]);
418                i += 1;
419                j += 1;
420            }
421        }
422    }
423    out
424}
425
426pub(crate) fn compress(codec: u8, bytes: &[u8]) -> Vec<u8> {
427    match codec {
428        // Level and frame shape are writer knobs (`crate::write_opts`); any
429        // combination decodes with every conformant zstd decoder.
430        #[cfg(feature = "compression")]
431        CODEC_ZSTD => {
432            let level = crate::write_opts::zstd_level();
433            match crate::write_opts::frame_shape() {
434                crate::write_opts::FrameShape::Stream => zstd::encode_all(bytes, level),
435                crate::write_opts::FrameShape::Sized => zstd::bulk::compress(bytes, level),
436            }
437            .expect("zstd encode is infallible in-memory")
438        }
439        _ => bytes.to_vec(),
440    }
441}
442
443/// The streaming twin of [`compress`]: the same codec, level and frame shape,
444/// read from `src` (exactly `src_len` bytes) and written to `dst`, for a
445/// payload the memory-bounded build keeps on disk (the TEXT_INDEX token
446/// table). A streaming frame is what `zstd::encode_all` writes, through the
447/// same `Encoder`. A sized frame is told the source size up front
448/// (`set_pledged_src_size`), so it picks the same small-input parameters and
449/// records the same content size as `zstd::bulk::compress`, without holding
450/// the payload. The encoder cuts blocks from its own input buffer, not from
451/// how the input was handed to it, so how `src` hands out its bytes never
452/// changes the output.
453///
454/// It equals [`compress`]'s one-shot output only while the payload fits the
455/// level's window (4 MiB at level 9): past it the streaming encoder's ring
456/// buffer wraps and it finds slightly different matches. A payload that one
457/// writer streams and another holds whole must therefore go through
458/// [`compress_streamed`] on the in-memory side — the token table does.
459/// `text_index_spill` pins both facts.
460#[cfg(not(target_arch = "wasm32"))]
461pub(crate) fn compress_to(
462    codec: u8,
463    src_len: u64,
464    src: &mut dyn std::io::Read,
465    dst: &mut dyn std::io::Write,
466) -> std::io::Result<()> {
467    match codec {
468        #[cfg(feature = "compression")]
469        CODEC_ZSTD => zstd_stream(src_len, src, dst),
470        _ => {
471            let _ = src_len;
472            std::io::copy(src, dst).map(|_| ())
473        }
474    }
475}
476
477/// [`compress`] through the streaming encoder: byte for byte what
478/// [`compress_to`] writes for the same payload, whatever its size. For the
479/// in-memory writer of a payload the memory-bounded build compresses from
480/// disk (the TEXT_INDEX token table), so the two builds write the same bytes
481/// past the zstd window too. Below it this is [`compress`]'s output.
482pub(crate) fn compress_streamed(codec: u8, bytes: &[u8]) -> Vec<u8> {
483    match codec {
484        #[cfg(feature = "compression")]
485        CODEC_ZSTD => {
486            let mut out = Vec::with_capacity(bytes.len() / 4 + 64);
487            zstd_stream(bytes.len() as u64, &mut &bytes[..], &mut out)
488                .expect("zstd encode is infallible in-memory");
489            out
490        }
491        _ => bytes.to_vec(),
492    }
493}
494
495/// The streaming zstd encoder at the writer's level and frame shape.
496#[cfg(feature = "compression")]
497fn zstd_stream(
498    src_len: u64,
499    src: &mut dyn std::io::Read,
500    dst: &mut dyn std::io::Write,
501) -> std::io::Result<()> {
502    let mut enc = zstd::stream::Encoder::new(dst, crate::write_opts::zstd_level())?;
503    if crate::write_opts::frame_shape() == crate::write_opts::FrameShape::Sized {
504        enc.set_pledged_src_size(Some(src_len))?;
505        enc.include_contentsize(true)?;
506    }
507    let copied = std::io::copy(src, &mut enc)?;
508    if copied != src_len {
509        return Err(std::io::Error::other(format!(
510            "zstd stream: {copied} bytes read, {src_len} announced"
511        )));
512    }
513    enc.finish()?;
514    Ok(())
515}
516
517pub(crate) fn decompress(codec: u8, bytes: &[u8]) -> Result<Vec<u8>, FileError> {
518    match codec {
519        CODEC_NONE => Ok(bytes.to_vec()),
520        // Native (the `compression` feature — always on for the CLI, off for
521        // wasm) decodes through the C `zstd` library, the same one
522        // [`compress`] encodes with. It is roughly 2× the pure-Rust decoder
523        // and speeds every read path, not just export; the multi-pass tight
524        // budget of a bounded export (`--memory-budget-mb`) re-decodes the
525        // dictionary, so a faster decoder is the single largest lever there.
526        // A valid zstd frame decodes to the same bytes under any conformant
527        // decoder, so the output is byte-for-byte what `ruzstd` produced.
528        #[cfg(feature = "compression")]
529        CODEC_ZSTD => zstd::decode_all(bytes).map_err(FileError::Decompress),
530        // Pure-Rust decode so a target WITHOUT a C toolchain (wasm, or a
531        // `--no-default-features` build) can still read compressed files.
532        // This is the ONLY decode path wasm ever compiles — the C `zstd`
533        // crate is gated behind `compression`, which wasm never enables.
534        #[cfg(not(feature = "compression"))]
535        CODEC_ZSTD => {
536            use std::io::Read;
537            let mut dec = ruzstd::decoding::StreamingDecoder::new(bytes)
538                .map_err(|e| FileError::Decompress(std::io::Error::other(e.to_string())))?;
539            let mut out = Vec::new();
540            dec.read_to_end(&mut out).map_err(FileError::Decompress)?;
541            Ok(out)
542        }
543        other => Err(FileError::UnknownCodec(other)),
544    }
545}
546
547/// The `Frame_Content_Size` a zstd frame header declares (RFC 8878 §3.1.1.1),
548/// or `None` when the frame does not state it (or `bytes` is not a zstd
549/// frame). Reads the header only.
550pub(crate) fn zstd_frame_content_size(b: &[u8]) -> Option<u64> {
551    if b.len() < 5 || b[..4] != [0x28, 0xB5, 0x2F, 0xFD] {
552        return None;
553    }
554    let fhd = b[4];
555    let fcs_flag = fhd >> 6;
556    let single_segment = fhd & 0x20 != 0;
557    let did_size = [0usize, 1, 2, 4][(fhd & 0x03) as usize];
558    let mut pos = 5 + usize::from(!single_segment) + did_size;
559    let fcs_size = match (fcs_flag, single_segment) {
560        (0, false) => return None,
561        (0, true) => 1,
562        (1, _) => 2,
563        (2, _) => 4,
564        _ => 8,
565    };
566    let f = b.get(pos..pos + fcs_size)?;
567    pos += fcs_size;
568    let _ = pos;
569    let mut v = 0u64;
570    for (i, x) in f.iter().enumerate() {
571        v |= (*x as u64) << (8 * i);
572    }
573    Some(if fcs_size == 2 { v + 256 } else { v })
574}
575
576/// [`decompress`] with the output bounded by `max` bytes: a frame whose header
577/// declares more is refused before any decoding, and a frame that does not
578/// declare its size is decoded at most `max + 1` bytes far and refused past
579/// `max`. Format `0x06` records each section's largest block, so a corrupt or
580/// hostile frame cannot make a reader allocate more than the writer did.
581pub(crate) fn decompress_bounded(codec: u8, bytes: &[u8], max: u64) -> Result<Vec<u8>, FileError> {
582    let too_big = |n: u64| {
583        FileError::Decompress(std::io::Error::other(format!(
584            "a block of {n} bytes exceeds its section's {max}-byte maximum"
585        )))
586    };
587    match codec {
588        CODEC_NONE => {
589            if bytes.len() as u64 > max {
590                return Err(too_big(bytes.len() as u64));
591            }
592            Ok(bytes.to_vec())
593        }
594        CODEC_ZSTD => {
595            let declared = zstd_frame_content_size(bytes);
596            if let Some(n) = declared.filter(|&n| n > max) {
597                return Err(too_big(n));
598            }
599            #[cfg(feature = "compression")]
600            {
601                if let Some(n) = declared {
602                    // One-shot into exactly the declared size: a frame that
603                    // lies about it fails here instead of growing the buffer.
604                    return zstd::bulk::decompress(bytes, n as usize)
605                        .map_err(FileError::Decompress);
606                }
607                use std::io::Read;
608                let mut out = Vec::new();
609                let mut dec = zstd::stream::read::Decoder::with_buffer(bytes)
610                    .map_err(FileError::Decompress)?;
611                dec.window_log_max(27).map_err(FileError::Decompress)?;
612                dec.take(max.saturating_add(1))
613                    .read_to_end(&mut out)
614                    .map_err(FileError::Decompress)?;
615                if out.len() as u64 > max {
616                    return Err(too_big(out.len() as u64));
617                }
618                Ok(out)
619            }
620            #[cfg(not(feature = "compression"))]
621            {
622                use std::io::Read;
623                let dec = ruzstd::decoding::StreamingDecoder::new(bytes)
624                    .map_err(|e| FileError::Decompress(std::io::Error::other(e.to_string())))?;
625                let mut out = Vec::new();
626                dec.take(max.saturating_add(1))
627                    .read_to_end(&mut out)
628                    .map_err(FileError::Decompress)?;
629                if out.len() as u64 > max {
630                    return Err(too_big(out.len() as u64));
631                }
632                Ok(out)
633            }
634        }
635        other => Err(FileError::UnknownCodec(other)),
636    }
637}
638
639/// The decompressed length of one stored block — from its frame header when
640/// it states it, else by decoding it (bounded by `ceiling`). What the `0x06`
641/// writer records as a section's largest block.
642pub(crate) fn block_raw_len(codec: u8, bytes: &[u8], ceiling: u64) -> Result<u64, FileError> {
643    match codec {
644        CODEC_NONE => Ok(bytes.len() as u64),
645        CODEC_ZSTD => match zstd_frame_content_size(bytes) {
646            Some(n) => Ok(n),
647            None => Ok(decompress_bounded(codec, bytes, ceiling)?.len() as u64),
648        },
649        other => Err(FileError::UnknownCodec(other)),
650    }
651}
652
653/// Bytes this close are cheaper fetched as one read than as two round trips:
654/// tiles are laid back-to-back so this only ever bridges a tile already made
655/// resident by an earlier window — keep it tight to avoid re-fetching it.
656const TILE_COALESCE_GAP: u64 = 4096;
657
658/// The dictionary chunks a query's output terms touch are scattered across the
659/// section (terms are sorted, output ids are not), so byte-adjacency is rare.
660/// A wider gap trades a little over-fetch for far fewer round trips — the right
661/// call on a latency-bound remote read, where one skipped 64 KiB chunk is much
662/// cheaper than another request's RTT.
663const DICT_COALESCE_GAP: u64 = 64 * 1024;
664
665/// The default `rete export --memory-budget-mb` when the flag is omitted — the
666/// same number `rete build`/`merge` default to, so one figure governs both the
667/// build and the export of a file. Comfortably above a sub-4-GB-dictionary
668/// file's decompressed dictionary (those export in a single pass at today's
669/// speed), and far under a large VM's RAM so a 50-GB-class file is bounded
670/// rather than OOM. See [`split_memory_budget`].
671pub const DEFAULT_EXPORT_BUDGET_MB: u64 = 4096;
672
673/// The largest default memory budget, in MiB, of a lazily-opened file — the
674/// bound on its dictionary chunk cache, index tile cache and raw block cache,
675/// split as [`split_memory_budget`] describes. The default actually applied is
676/// sized from the memory the process can still use, half of it, between
677/// [`crate::sysmem::MIN_DEFAULT_QUERY_BUDGET_MB`] and this figure (see
678/// [`crate::sysmem::default_query_budget_mb`]; this figure whenever the memory
679/// cannot be read). `rete sparql --memory-budget-mb` (and the other query
680/// commands) default to it too, and so does every client that opens through
681/// [`Rete::open_ranged_lazy`] (Python, R, Java, the relay), so a long-lived
682/// session no longer grows with every tile a query ever touched. The same
683/// figure as [`DEFAULT_EXPORT_BUDGET_MB`].
684pub const DEFAULT_QUERY_BUDGET_MB: u64 = DEFAULT_EXPORT_BUDGET_MB;
685
686/// The [`Rete::spill_threshold`] a memory budget implies: an eighth of it. A
687/// spilling `COUNT(DISTINCT …)` buffer can briefly occupy about twice its
688/// threshold while it sorts, so this keeps it within a quarter of the budget.
689pub fn spill_threshold_for(budget: u64) -> u64 {
690    budget / 8
691}
692
693/// The lowest byte cap the dictionary chunk cache is ever given, whatever the
694/// budget. It only has to guarantee forward progress and hold a handful of
695/// chunks (a single front-coded run can dwarf the 64 KiB chunk budget — a
696/// base64-embedded image, say). Correctness never depends on it: the cache's
697/// `Arc` handout keeps a chunk alive across its one decode even if it is
698/// evicted the instant it is inserted, so a full dump completes at *any* cap.
699/// This floor only keeps a pathologically small `--memory-budget-mb` from
700/// thrashing the whole dictionary chunk-by-chunk.
701const MIN_DICT_CACHE_CAP: u64 = 64 << 20; // 64 MiB
702
703/// Upper bound of the budget share [`split_memory_budget`] holds back for
704/// transient, uncached allocations (see the `reserve` there).
705const TRANSIENT_RESERVE_MAX: u64 = 128 << 20; // 128 MiB
706
707/// How a total `--memory-budget-mb` splits across the export's three bounded
708/// consumers. The three caps sum to the total less a small reserve (≤ 128 MiB)
709/// for the window's buffers and one window's in-flight reads, which no cache
710/// owns. [`Rete::set_memory_budget`] further subtracts the open's fixed
711/// directories ([`Rete::fixed_overhead`]) from the dictionary share, so the
712/// live heap of a dump stays within the total.
713#[derive(Debug, Clone, Copy)]
714pub struct MemoryBudget {
715    /// Raw file-block LRU (the `BlockCacheReader` that backs the ranged reader).
716    pub block_cache: u64,
717    /// Decompressed dictionary chunk bodies (the old ~4.3 GB export floor).
718    pub dict_cache: u64,
719    /// Decoded index tiles of the scanned permutation.
720    pub tile_cache: u64,
721}
722
723/// Split a total byte budget across the block cache, the dictionary chunk cache
724/// and the index tile cache (bounded-export phase 2, plan §5.2). `u64::MAX`
725/// (unlimited, e.g. `--in-memory` or `--memory-budget-mb 0`) passes straight
726/// through to every consumer.
727///
728/// The dictionary is the dominant consumer on scholar files (literal-heavy
729/// object section), and its chunks recur across windows, so it gets the bulk of
730/// the budget; the tile cache is small (a scanned permutation is far smaller
731/// than the dictionary) and the block cache stays modest, capped like every
732/// other ranged reader at [`crate::block_cache::DEFAULT_CACHE_CAP`].
733pub fn split_memory_budget(total: u64) -> MemoryBudget {
734    if total == u64::MAX {
735        return MemoryBudget {
736            block_cache: u64::MAX,
737            dict_cache: u64::MAX,
738            tile_cache: u64::MAX,
739        };
740    }
741    // Raw file blocks: min(256 MiB, total/8). Repeated directory/header reads
742    // must stay cheap, but this cache is not where the floor lived.
743    let block_cache = (total / 8).min(crate::block_cache::DEFAULT_CACHE_CAP);
744    // Scanned-permutation tiles: modest — tens of MB on epfl-class files. Cap
745    // at total/16 (≤ 256 MiB) so the dictionary keeps the rest.
746    let tile_cache = (total / 16).min(256 << 20);
747    // Held back for what no cache owns: the window's id and string buffers,
748    // the coalesced read spans and freshly decoded bodies of one window's
749    // prefetch before they enter the cache, and the program itself. Measured at
750    // ~80-120 MB on epfl and openaire-relation (dev/export-memory), so 1/32 of
751    // the budget, capped at 128 MiB.
752    let reserve = (total / 32).min(TRANSIENT_RESERVE_MAX);
753    // The dictionary chunk cache gets everything else, floored so a tiny budget
754    // still holds a working set of chunks.
755    let dict_cache = total
756        .saturating_sub(block_cache)
757        .saturating_sub(tile_cache)
758        .saturating_sub(reserve)
759        .max(MIN_DICT_CACHE_CAP);
760    MemoryBudget {
761        block_cache,
762        dict_cache,
763        tile_cache,
764    }
765}
766
767/// Heap an open `Rete` holds outside its bounded caches, by structure — see
768/// [`Rete::fixed_overhead`].
769#[derive(Debug, Clone, Copy, Default)]
770pub struct FixedOverhead {
771    /// The four dictionary sections' chunk directories.
772    pub dict_directory: u64,
773    /// The default graph's tile directories (every carried permutation).
774    pub index_directory: u64,
775    /// The named-graph directory plus the open graphs' tile directories.
776    pub named_graphs: u64,
777}
778
779impl FixedOverhead {
780    /// The sum of every part.
781    pub fn total(&self) -> u64 {
782        self.dict_directory + self.index_directory + self.named_graphs
783    }
784}
785
786/// Format a cache byte cap for `RETE_OPEN_DEBUG` (`unlimited` for `u64::MAX`).
787fn fmt_cap(cap: u64) -> String {
788    if cap == u64::MAX {
789        "unlimited".to_string()
790    } else {
791        format!("{}MiB", cap >> 20)
792    }
793}
794
795/// Fetch a set of ascending, disjoint byte ranges, coalescing ranges whose gap
796/// is at most `gap` into one span, then fetching the spans through
797/// [`RangeReader::read_many`] (which a parallelizable reader issues
798/// concurrently). Returns each requested range's bytes in order; `None` if any
799/// read fails.
800pub(crate) fn read_coalesced<R: RangeReader + ?Sized>(
801    reader: &R,
802    ranges: &[ByteRange],
803    gap: u64,
804) -> Option<Vec<Vec<u8>>> {
805    // Build the coalesced spans and remember which span each input range maps
806    // into, so the fetched span blobs can be sliced back apart in order.
807    let mut spans: Vec<(u64, u64)> = Vec::new();
808    let mut span_of: Vec<usize> = Vec::with_capacity(ranges.len());
809    let mut i = 0;
810    while i < ranges.len() {
811        let start = ranges[i].offset;
812        let mut end = ranges[i].offset.checked_add(ranges[i].len)?;
813        let mut j = i + 1;
814        while j < ranges.len() {
815            let r = &ranges[j];
816            if r.offset < end || r.offset - end > gap {
817                break;
818            }
819            end = r.offset.checked_add(r.len)?;
820            j += 1;
821        }
822        let si = spans.len();
823        spans.push((start, end - start));
824        for _ in i..j {
825            span_of.push(si);
826        }
827        i = j;
828    }
829    let blobs = reader.read_many(&spans).ok()?;
830    if blobs.len() != spans.len() {
831        return None;
832    }
833    let mut out = Vec::with_capacity(ranges.len());
834    for (k, r) in ranges.iter().enumerate() {
835        let (span_start, _) = spans[span_of[k]];
836        let blob = &blobs[span_of[k]];
837        out.push(checked_slice(blob, r.offset - span_start, r.len)?.to_vec());
838    }
839    Some(out)
840}
841
842/// Content hash (first 16 bytes of blake3) over the file payload sections.
843/// Identifies the immutable content independent of the header.
844fn content_hash(parts: &[&[u8]]) -> [u8; 16] {
845    let mut h = blake3::Hasher::new();
846    for p in parts {
847        h.update(p);
848    }
849    let mut out = [0u8; 16];
850    out.copy_from_slice(&h.finalize().as_bytes()[..16]);
851    out
852}
853
854/// A resolved triple as terms.
855pub type TermTriple = (String, String, String);
856
857/// What a filtered dump will cost, before it runs — see
858/// [`Rete::dump_plan`](crate::Rete::dump_plan).
859#[derive(Debug, Clone, Copy, PartialEq, Eq)]
860pub struct DumpPlan {
861    /// The routed scan this dump will run. `None` when it provably matches
862    /// nothing without touching the index: an absent graph IRI, or a bound term
863    /// the dictionary does not contain.
864    pub scan: Option<crate::index::ScanPlan>,
865    /// The file's whole dictionary section — the **ceiling** on what resolving
866    /// the matched rows' terms can fault, and on a literal-heavy file the
867    /// dominant term in a dump's real cost.
868    ///
869    /// It is deliberately not an estimate. A dump that matches one row faults
870    /// only the chunks that row's three terms live in, but nothing in the tile
871    /// directories says which chunks those are or how big they are, and a
872    /// preview that guessed would be worse than one that states the bound.
873    pub dictionary_bytes: u64,
874}
875
876/// One labelled byte region of a `.rete` file image (see
877/// [`Rete::file_layout`]). `kind` is a stable machine tag: `header`,
878/// `metadata`, `dictionary`, `directory`, `tile`, `pyramid`, `named-graphs`.
879#[derive(Debug, Clone)]
880pub struct LayoutSegment {
881    pub kind: &'static str,
882    pub label: String,
883    pub offset: u64,
884    pub len: u64,
885}
886
887/// A byte range in the `.rete` file image.
888#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
889pub struct ByteRange {
890    pub offset: u64,
891    pub len: u64,
892}
893
894impl ByteRange {
895    pub fn end(self) -> u64 {
896        self.offset + self.len
897    }
898}
899
900/// Why a triple-pattern result is present in the file.
901#[derive(Debug, Clone, PartialEq, Eq)]
902pub struct TripleProvenance {
903    /// Matched triple resolved to canonical N-Triples tokens.
904    pub terms: TermTriple,
905    /// Matched triple in dictionary ID space.
906    pub ids: Triple,
907    /// Named graph IRI, or `None` for the default graph.
908    pub graph: Option<String>,
909    /// The resolved ID-space pattern that was matched.
910    pub matched_pattern: Pattern,
911    /// Permutation selected to answer the pattern.
912    pub index_permutation: IndexPermutation,
913    /// File byte range containing the dictionary container.
914    pub dictionary_range: ByteRange,
915    /// File byte range containing the permutation index container.
916    pub index_range: ByteRange,
917    /// File byte range containing the selected permutation payload inside the
918    /// index container.
919    pub index_section_range: ByteRange,
920    /// File byte range containing the pyramid metadata, when present.
921    pub pyramid_range: Option<ByteRange>,
922    /// Physical tile identifier, once tile directories are materialized.
923    /// Physical tile identifier (`PERM/index`, e.g. `POS/3`) for tiled (v0.2)
924    /// files; `None` for pre-tiling files.
925    pub tile: Option<String>,
926    /// File byte range of that (compressed) tile — the exact bytes a ranged
927    /// client would fetch to re-derive this match.
928    pub tile_range: Option<ByteRange>,
929}
930
931/// Encode a length-prefixed container of byte sections, each compressed with
932/// `codec` independently (so a range-reading client decompresses only the
933/// sections it fetches). Stored length is the *compressed* length.
934fn encode_container(sections: &[&[u8]], codec: u8) -> Vec<u8> {
935    let mut out = Vec::new();
936    write_uvarint(&mut out, sections.len() as u64);
937    for s in sections {
938        let payload = compress(codec, s);
939        write_uvarint(&mut out, payload.len() as u64);
940        out.extend_from_slice(&payload);
941    }
942    out
943}
944
945/// Decode a container into owned, decompressed sections.
946fn decode_container(bytes: &[u8], codec: u8) -> Result<Vec<Vec<u8>>, FileError> {
947    let (n, mut pos) = read_uvarint(bytes).ok_or(FileError::Container("truncated count"))?;
948    // `n` is untrusted; each section needs ≥1 byte, so cap the pre-allocation at
949    // the buffer length rather than trusting the count (avoids an OOM on a bogus
950    // header pointing at a small region).
951    let n = wire_usize(n, "section count too large")?;
952    let mut out = Vec::with_capacity(n.min(bytes.len()));
953    for _ in 0..n {
954        let (len, used) = read_uvarint(bytes.get(pos..).unwrap_or(&[]))
955            .ok_or(FileError::Container("truncated length"))?;
956        pos += used;
957        // Checked: a hostile length near u64::MAX used to overflow `pos + len`
958        // (a panic), and on wasm32 `len as usize` kept the low 32 bits.
959        let end = pos
960            .checked_add(wire_usize(len, "section length too large")?)
961            .filter(|&end| end <= bytes.len())
962            .ok_or(FileError::Container("section overruns buffer"))?;
963        out.push(decompress(codec, &bytes[pos..end])?);
964        pos = end;
965    }
966    Ok(out)
967}
968
969fn checked_end(off: u64, len: u64) -> Result<u64, FileError> {
970    off.checked_add(len)
971        .ok_or(FileError::Container("section range overflows"))
972}
973
974/// A length or count read from the file, as an in-memory `usize` — or a clean
975/// error. `as usize` truncates on wasm32, a 32-bit target: a length of
976/// `2^32 + 5` read 5 bytes there and decoding went on as if nothing were wrong.
977fn wire_usize(v: u64, what: &'static str) -> Result<usize, FileError> {
978    usize::try_from(v).map_err(|_| FileError::Container(what))
979}
980
981/// The in-memory slice `[off, off + len)` of `bytes`, or `None` when the range
982/// does not fit this target's `usize` or overruns the buffer.
983fn checked_slice(bytes: &[u8], off: u64, len: u64) -> Option<&[u8]> {
984    let start = usize::try_from(off).ok()?;
985    let end = start.checked_add(usize::try_from(len).ok()?)?;
986    bytes.get(start..end)
987}
988
989/// Per-chunk budget for dictionary section bodies — same reasoning as
990/// [`crate::index::INDEX_TILE_BUDGET`]: one fetch, one decompress per touch.
991const DICT_CHUNK_BUDGET: usize = crate::write_opts::DEFAULT_DICT_CHUNK_BYTES;
992
993/// Encode one dictionary section as a chunked payload (format v0.2):
994/// `[header_len, raw header (term_count/interval/restart table)]
995///  [num_chunks; per chunk: Δfirst_run, key_len, key, comp_len]
996///  [individually compressed run-aligned body slices]`.
997/// The header keeps its original encoding, so restart offsets stay valid in
998/// the section's coordinate space.
999///
1000/// `key` is the chunk's **routing separator**, not its first term: the
1001/// shortest string strictly above the previous chunk's last term and at most
1002/// the chunk's own first term (empty for chunk 0). See
1003/// [`crate::dict::SectionChunk::key`] for the invariant and
1004/// [`crate::dict::shortest_separator`] for the choice. Storing the first term
1005/// verbatim — what this wrote before — is the degenerate case of the same
1006/// invariant, so readers of either vintage route both correctly; the
1007/// separator is simply orders of magnitude smaller when the boundary term is a
1008/// long literal.
1009fn encode_chunked_dict_section(raw: &[u8], codec: u8) -> Vec<u8> {
1010    let meta = crate::dict::parse_meta(raw).unwrap_or(crate::dict::SectionMeta {
1011        term_count: 0,
1012        restart_interval: 1,
1013        restart_offsets: Vec::new(),
1014    });
1015    let body_start = meta
1016        .restart_offsets
1017        .first()
1018        .copied()
1019        .unwrap_or(raw.len() as u64);
1020    let header = &raw[..(body_start.min(raw.len() as u64)) as usize];
1021
1022    // Split runs into chunks by body-byte budget (whole runs only).
1023    let chunk_budget = crate::write_opts::dict_chunk_budget() as u64;
1024    let n_runs = meta.restart_offsets.len();
1025    let mut bounds: Vec<(usize, u64, u64)> = Vec::new(); // (first_run, start, end)
1026    let mut r = 0;
1027    while r < n_runs {
1028        let start = meta.restart_offsets[r];
1029        let mut r2 = r + 1;
1030        while r2 < n_runs && meta.restart_offsets[r2] - start < chunk_budget {
1031            r2 += 1;
1032        }
1033        let end = if r2 < n_runs {
1034            meta.restart_offsets[r2]
1035        } else {
1036            raw.len() as u64
1037        };
1038        bounds.push((r, start, end));
1039        r = r2;
1040    }
1041
1042    // Chunks are independent: compress them across all cores, as the index
1043    // tiles are (`par_iter` keeps the order, so the bytes are unchanged). At a
1044    // high `--zstd-level` this serial loop was most of a build's wall time.
1045    #[cfg(feature = "parallel")]
1046    let compressed: Vec<Vec<u8>> = {
1047        use rayon::prelude::*;
1048        bounds
1049            .par_iter()
1050            .map(|&(_, s, e)| compress(codec, &raw[s as usize..e as usize]))
1051            .collect()
1052    };
1053    #[cfg(not(feature = "parallel"))]
1054    let compressed: Vec<Vec<u8>> = bounds
1055        .iter()
1056        .map(|&(_, s, e)| compress(codec, &raw[s as usize..e as usize]))
1057        .collect();
1058    let mut out = Vec::new();
1059    write_uvarint(&mut out, header.len() as u64);
1060    out.extend_from_slice(header);
1061    write_uvarint(&mut out, bounds.len() as u64);
1062    let mut prev_run = 0usize;
1063    // The previous chunk's last term — the separator's lower bound. Decoding it
1064    // walks at most `restart_interval` front-coded entries of one chunk, which
1065    // is nothing beside the zstd pass over the same 64 KiB.
1066    let mut prev_last: Option<Vec<u8>> = None;
1067    for (i, (&(first_run, start, end), comp)) in bounds.iter().zip(&compressed).enumerate() {
1068        let key = if i == 0 {
1069            // Everything routes to chunk 0; it needs no separator at all.
1070            Vec::new()
1071        } else {
1072            let first = crate::dict::run_first_term(raw, start as usize).unwrap_or_default();
1073            match &prev_last {
1074                // Undecodable predecessor: the verbatim first term is always a
1075                // valid separator, just not a short one.
1076                None => first,
1077                Some(pl) => crate::dict::shortest_separator(pl, &first),
1078            }
1079        };
1080        if i + 1 < bounds.len() {
1081            // The last run of THIS chunk starts one run before the next chunk's.
1082            let last_run_off = meta.restart_offsets[bounds[i + 1].0 - 1] as usize;
1083            prev_last = crate::dict::run_last_term(raw, last_run_off, end as usize);
1084        }
1085        write_uvarint(&mut out, (first_run - prev_run) as u64);
1086        write_uvarint(&mut out, key.len() as u64);
1087        out.extend_from_slice(&key);
1088        write_uvarint(&mut out, comp.len() as u64);
1089        prev_run = first_run;
1090    }
1091    for comp in &compressed {
1092        out.extend_from_slice(comp);
1093    }
1094    out
1095}
1096
1097/// A parsed chunked-dict-section directory entry: the chunk's run/key/body
1098/// coordinates plus its compressed byte range *within the payload*. `key` is a
1099/// routing separator, not a term — see [`crate::dict::SectionChunk::key`].
1100pub(crate) struct DictChunkEntry {
1101    pub(crate) first_run: usize,
1102    pub(crate) key: Vec<u8>,
1103    body_start: u64,
1104    pub(crate) start: u64,
1105    pub(crate) end: u64,
1106}
1107
1108/// Parse a chunked dictionary section's header + directory (not the chunks).
1109/// `bytes` may be a prefix of the payload; compressed ranges validate against
1110/// `total_len`.
1111fn parse_chunked_dict_dir(
1112    bytes: &[u8],
1113    total_len: u64,
1114) -> Result<(crate::dict::SectionMeta, Vec<DictChunkEntry>), FileError> {
1115    let mut pos = 0usize;
1116    let take = |pos: &mut usize| -> Result<u64, FileError> {
1117        let (v, n) = read_uvarint(bytes.get(*pos..).unwrap_or(&[]))
1118            .ok_or(FileError::Container("truncated dict chunk directory"))?;
1119        *pos += n;
1120        Ok(v)
1121    };
1122    let header_len = wire_usize(take(&mut pos)?, "dict header too large")?;
1123    let header = bytes
1124        .get(pos..pos.saturating_add(header_len))
1125        .ok_or(FileError::Container("truncated dict header"))?;
1126    let meta = crate::dict::parse_meta(header)
1127        .map_err(|_| FileError::Container("malformed dict header"))?;
1128    pos += header_len;
1129
1130    let num_chunks = wire_usize(take(&mut pos)?, "dict chunk count too large")?;
1131    let mut entries = Vec::with_capacity(num_chunks.min(bytes.len()));
1132    let mut lens = Vec::with_capacity(num_chunks.min(bytes.len()));
1133    let mut prev_run = 0usize;
1134    for i in 0..num_chunks {
1135        let drun = wire_usize(take(&mut pos)?, "dict chunk run too large")?;
1136        let klen = wire_usize(take(&mut pos)?, "dict chunk key too large")?;
1137        let key = bytes
1138            .get(pos..pos.saturating_add(klen))
1139            .ok_or(FileError::Container("truncated dict chunk key"))?
1140            .to_vec();
1141        pos += klen;
1142        let clen = take(&mut pos)?;
1143        let first_run = next_chunk_run(i, prev_run, drun)?;
1144        let body_start = meta
1145            .restart_offsets
1146            .get(first_run)
1147            .copied()
1148            .ok_or(FileError::Container("dict chunk run out of range"))?;
1149        entries.push(DictChunkEntry {
1150            first_run,
1151            key,
1152            body_start,
1153            start: 0,
1154            end: 0,
1155        });
1156        lens.push(clen);
1157        prev_run = first_run;
1158    }
1159    let mut start = pos as u64;
1160    for (e, len) in entries.iter_mut().zip(lens) {
1161        let end = start
1162            .checked_add(len)
1163            .filter(|&e| e <= total_len)
1164            .ok_or(FileError::Container("dict chunk overruns section"))?;
1165        e.start = start;
1166        e.end = end;
1167        start = end;
1168    }
1169    Ok((meta, entries))
1170}
1171
1172/// The `i`-th chunk's first run from the previous chunk's and the stored delta.
1173/// Both writers start chunk 0 at run 0 and give every later chunk at least one
1174/// run, so the runs strictly ascend — which is what routing a term id to its
1175/// chunk (a binary search over `first_run`) relies on. A directory that breaks
1176/// that, or whose sum overflows, is corrupt: refuse it instead of routing ids
1177/// to the wrong chunk.
1178fn next_chunk_run(i: usize, prev_run: usize, drun: usize) -> Result<usize, FileError> {
1179    match (i, drun) {
1180        (0, 0) => Ok(0),
1181        (0, _) => Err(FileError::Container(
1182            "first dict chunk does not start at run 0",
1183        )),
1184        (_, 0) => Err(FileError::Container("dict chunk runs do not ascend")),
1185        _ => prev_run
1186            .checked_add(drun)
1187            .ok_or(FileError::Container("dict chunk run overflows")),
1188    }
1189}
1190
1191/// Fetch and parse a remote chunked dict section's header + directory: read a
1192/// small prefix and grow it geometrically until it parses, never fetching past
1193/// the section.
1194/// Read a chunked dictionary section's directory over a range reader WITHOUT
1195/// materializing the section-wide restart table. That table is one offset per
1196/// restart run — a 50 M-term section has ~3 M of them (~24 MiB resident), and
1197/// holding it is an iOS-Safari OOM on a big remote file. We read only the tiny
1198/// header prefix (term_count / interval) and the chunk directory, skipping the
1199/// restart-table bytes entirely; per-run offsets are derived per chunk on fault
1200/// (`SectionChunk::run_offsets`). The returned meta has an empty
1201/// `restart_offsets`, which the chunked lookups read as "derive per chunk".
1202pub(crate) fn read_dict_dir_ranged<R: RangeReader + ?Sized>(
1203    reader: &R,
1204    section: ByteRange,
1205) -> Result<(crate::dict::SectionMeta, Vec<DictChunkEntry>), FileError> {
1206    let total = section.len;
1207    // Initial prefix: the header prefix ([header_len][term_count][interval]) and,
1208    // for a *small* section, the whole chunk directory too — so those still cost
1209    // a single read. A big section has a huge restart table between the header
1210    // and the directory; we detect that (dir_start past the prefix) and range-
1211    // read only the directory below, never fetching the table.
1212    let init = 8192.min(total); // never over-read past the section (a tiny/empty
1213                                // section holds only its header + a stub directory)
1214    let head = reader.read_at(section.offset, init)?;
1215    let (header_len, n0) =
1216        read_uvarint(&head).ok_or(FileError::Container("truncated dict header len"))?;
1217    let hbase = n0; // first byte of the header body
1218    let (term_count, n1) = read_uvarint(head.get(hbase..).unwrap_or(&[]))
1219        .ok_or(FileError::Container("truncated dict term_count"))?;
1220    let (restart_interval, _n2) = read_uvarint(head.get(hbase + n1..).unwrap_or(&[]))
1221        .ok_or(FileError::Container("truncated dict interval"))?;
1222    if restart_interval == 0 {
1223        return Err(FileError::Container("zero restart interval"));
1224    }
1225    // The chunk directory begins right after the header body — i.e. past the
1226    // `header_len` bytes, which include the restart table we never materialize.
1227    let dir_start = (hbase as u64)
1228        .checked_add(header_len)
1229        .filter(|&d| d <= total)
1230        .ok_or(FileError::Container("dict header overruns section"))?;
1231    let dir_total = total - dir_start;
1232    let meta = crate::dict::SectionMeta {
1233        term_count: u32::try_from(term_count)
1234            .map_err(|_| FileError::Container("dict term count too large"))?,
1235        restart_interval: u32::try_from(restart_interval)
1236            .map_err(|_| FileError::Container("dict restart interval too large"))?,
1237        restart_offsets: Vec::new(),
1238    };
1239    // Runs in the section: what a chunk's first run must stay below.
1240    let num_runs = (meta.term_count as usize).div_ceil(meta.restart_interval as usize);
1241    let finish = |mut entries: Vec<DictChunkEntry>| {
1242        for e in &mut entries {
1243            e.start += dir_start; // dir-relative → section-relative
1244            e.end += dir_start;
1245        }
1246        (meta.clone(), entries)
1247    };
1248    // Fast path: the directory already sits in the prefix we read (small section
1249    // — its restart table is tiny, so the ~few KiB over-read is negligible).
1250    // A short prefix is not wasted: those bytes seed the probe below.
1251    let mut have: Vec<u8> = Vec::new();
1252    if dir_start < head.len() as u64 {
1253        match parse_chunk_dir_only(&head[dir_start as usize..], dir_total, num_runs)? {
1254            ChunkDirParse::Done(entries) => return Ok(finish(entries)),
1255            ChunkDirParse::Truncated { .. } => have = head[dir_start as usize..].to_vec(),
1256        }
1257    }
1258    // Big section: range-read the directory on its own, skipping the table.
1259    //
1260    // Nothing records the directory's byte length, so it has to be probed. Two
1261    // things keep that probe near ONE directory's worth of bytes. Each round
1262    // **appends** to the bytes already held instead of re-reading from the
1263    // start — the previous loop re-read the whole prefix every time, so a
1264    // 234 MB directory (epfl-infoscience's object section) cost 537 MB of range
1265    // reads to fetch. And a truncated parse reports how many entries fit in how
1266    // many bytes, which extrapolates the rest instead of blindly doubling. The
1267    // extrapolation may only quadruple a round, so a wild guess on a directory
1268    // with wildly uneven entries (one stored term can be hundreds of KB) can
1269    // never fetch the section body.
1270    let mut want = 4096u64.min(dir_total).max(1);
1271    loop {
1272        let held = have.len() as u64;
1273        if want > held {
1274            let extra = reader.read_at(section.offset + dir_start + held, want - held)?;
1275            if extra.is_empty() {
1276                return Err(FileError::Container("truncated dict chunk directory"));
1277            }
1278            have.extend_from_slice(&extra);
1279        }
1280        let held = have.len() as u64;
1281        match parse_chunk_dir_only(&have, dir_total, num_runs)? {
1282            ChunkDirParse::Done(entries) => return Ok(finish(entries)),
1283            ChunkDirParse::Truncated { .. } if held >= dir_total => {
1284                return Err(FileError::Container("truncated dict chunk directory"));
1285            }
1286            ChunkDirParse::Truncated {
1287                parsed,
1288                used,
1289                total,
1290            } => {
1291                // Extrapolate from a sample big enough to mean something;
1292                // otherwise just double. Undershooting only costs another
1293                // (append-only) round, so the estimate carries a small margin
1294                // rather than a generous one.
1295                let est = if parsed >= 16 && used > 0 {
1296                    let whole = ((used as u64) / (parsed as u64)).saturating_mul(total as u64);
1297                    whole.saturating_add(whole / 8).saturating_add(64)
1298                } else {
1299                    // Too small a sample to extrapolate from: double.
1300                    held.saturating_mul(2)
1301                };
1302                want = est
1303                    .max(held.saturating_add(1))
1304                    .min(held.saturating_mul(4))
1305                    .min(dir_total);
1306            }
1307        }
1308    }
1309}
1310
1311/// What [`parse_chunk_dir_only`] made of a chunk-directory prefix: either the
1312/// whole directory, or how far a truncated one got (the probe's step hint).
1313/// Truncation is not an error here — the caller is deliberately reading a
1314/// prefix and deciding how much more to fetch.
1315enum ChunkDirParse {
1316    Done(Vec<DictChunkEntry>),
1317    Truncated {
1318        /// Entries fully decoded from the prefix.
1319        parsed: usize,
1320        /// Bytes those entries occupied (directory-relative).
1321        used: usize,
1322        /// Chunk count the directory declares.
1323        total: usize,
1324    },
1325}
1326
1327/// Parse just the chunk directory (the bytes after a section header):
1328/// `[num_chunks][per chunk: Δfirst_run, key_len, key, comp_len]`.
1329/// Chunk byte ranges (`start`/`end`) come back relative to the directory's own
1330/// start; `body_start` is 0 (a lite section never uses it — lookups derive run
1331/// offsets per chunk). Bodies aren't needed here, so `dir` may end at the first
1332/// body as long as it covers the whole directory. A `dir` that stops mid-entry
1333/// is reported as [`ChunkDirParse::Truncated`], not an error: only bytes that
1334/// cannot be a directory at any length (a chunk range overrunning the section,
1335/// chunk runs that do not ascend from 0 or pass `num_runs`) fail.
1336fn parse_chunk_dir_only(
1337    dir: &[u8],
1338    dir_total: u64,
1339    num_runs: usize,
1340) -> Result<ChunkDirParse, FileError> {
1341    let mut pos = 0usize;
1342    let take = |pos: &mut usize| -> Option<u64> {
1343        let (v, n) = read_uvarint(dir.get(*pos..).unwrap_or(&[]))?;
1344        *pos += n;
1345        Some(v)
1346    };
1347    let Some(num_chunks) = take(&mut pos) else {
1348        return Ok(ChunkDirParse::Truncated {
1349            parsed: 0,
1350            used: 0,
1351            total: 0,
1352        });
1353    };
1354    let num_chunks = wire_usize(num_chunks, "dict chunk count too large")?;
1355    let mut entries = Vec::with_capacity(num_chunks.min(dir.len()));
1356    let mut lens: Vec<u64> = Vec::with_capacity(num_chunks.min(dir.len()));
1357    let mut prev_run = 0usize;
1358    for i in 0..num_chunks {
1359        let entry_start = pos;
1360        let short = ChunkDirParse::Truncated {
1361            parsed: entries.len(),
1362            used: entry_start,
1363            total: num_chunks,
1364        };
1365        let (Some(drun), Some(klen)) = (take(&mut pos), take(&mut pos)) else {
1366            return Ok(short);
1367        };
1368        let drun = wire_usize(drun, "dict chunk run too large")?;
1369        let klen = wire_usize(klen, "dict chunk key too large")?;
1370        let Some(key) = dir.get(pos..pos.saturating_add(klen)) else {
1371            return Ok(short);
1372        };
1373        let key = key.to_vec();
1374        pos += klen;
1375        let Some(clen) = take(&mut pos) else {
1376            return Ok(short);
1377        };
1378        let first_run = next_chunk_run(i, prev_run, drun)?;
1379        if first_run >= num_runs {
1380            return Err(FileError::Container("dict chunk run out of range"));
1381        }
1382        entries.push(DictChunkEntry {
1383            first_run,
1384            key,
1385            body_start: 0,
1386            start: 0,
1387            end: 0,
1388        });
1389        lens.push(clen);
1390        prev_run = first_run;
1391    }
1392    let mut start = pos as u64;
1393    for (e, len) in entries.iter_mut().zip(lens) {
1394        let end = start
1395            .checked_add(len)
1396            .filter(|&e| e <= dir_total)
1397            .ok_or(FileError::Container("dict chunk overruns section"))?;
1398        e.start = start;
1399        e.end = end;
1400        start = end;
1401    }
1402    Ok(ChunkDirParse::Done(entries))
1403}
1404
1405/// Decode one chunked dictionary section payload into a resident
1406/// [`crate::dict::ChunkedSection`] (chunks decompressed up front — the local
1407/// open path).
1408fn decode_chunked_dict_section(
1409    payload: &[u8],
1410    codec: u8,
1411    cache: std::sync::Arc<crate::chunk_cache::ChunkCache>,
1412    section_index: u8,
1413) -> Result<crate::dict::ChunkedSection, FileError> {
1414    let (meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64)?;
1415    let chunks = entries
1416        .into_iter()
1417        .map(|e| {
1418            let body = decompress(codec, &payload[e.start as usize..e.end as usize])?;
1419            Ok((
1420                crate::dict::SectionChunk::new(e.first_run, e.key, e.body_start),
1421                body,
1422            ))
1423        })
1424        .collect::<Result<Vec<_>, FileError>>()?;
1425    Ok(crate::dict::ChunkedSection::resident(
1426        meta,
1427        chunks,
1428        cache,
1429        section_index,
1430    ))
1431}
1432
1433fn decode_dictionary_container(bytes: &[u8], codec: u8) -> Result<Dictionary, FileError> {
1434    let dsecs = decode_container(bytes, CODEC_NONE)?;
1435    if dsecs.len() != 4 {
1436        return Err(FileError::Container("expected 4 dictionary sections"));
1437    }
1438    // The four sections share one (unlimited) cache — residency is identical to
1439    // the previous per-chunk `OnceLock`s.
1440    let cache = crate::chunk_cache::ChunkCache::unlimited_arc();
1441    let mut sections = Vec::with_capacity(4);
1442    for (si, sec) in dsecs.iter().enumerate() {
1443        sections.push(decode_chunked_dict_section(
1444            sec,
1445            codec,
1446            cache.clone(),
1447            si as u8,
1448        )?);
1449    }
1450    let arr: [crate::dict::ChunkedSection; 4] = sections
1451        .try_into()
1452        .map_err(|_| FileError::Container("expected 4 dictionary sections"))?;
1453    Ok(Dictionary::from_chunked_sections(arr))
1454}
1455
1456/// Encode one permutation's tiled section payload (format v0.2):
1457/// `[num_tiles][per tile: delta(min_a), max_a - min_a, compressed_len][tiles…]`,
1458/// each tile compressed independently with `codec` so a ranged reader can
1459/// fetch and decompress exactly the tiles a query routes to. The directory
1460/// itself is uncompressed (it must be readable before any tile).
1461fn encode_tiled_section(index: &GraphIndex, si: usize, codec: u8) -> Vec<u8> {
1462    let tiles = index.tile_sections()[si];
1463    let n = tiles.len();
1464    // Tile bodies live in the index's chunk cache (keyed by (section, tile));
1465    // fetch each as a shared handle. For a locally-built index every tile is
1466    // resident, so this is a cheap `Arc` clone under an uncontended lock.
1467    // Per-tile compression is the bulk of serialization time on a large graph and
1468    // the tiles are independent, so compress them across all cores. `par_iter`
1469    // preserves order, so the output is byte-identical to the serial map.
1470    #[cfg(feature = "parallel")]
1471    let compressed: Vec<Vec<u8>> = {
1472        use rayon::prelude::*;
1473        (0..n)
1474            .into_par_iter()
1475            .map(|ti| compress(codec, &index.tile_body(si, ti)))
1476            .collect()
1477    };
1478    #[cfg(not(feature = "parallel"))]
1479    let compressed: Vec<Vec<u8>> = (0..n)
1480        .map(|ti| compress(codec, &index.tile_body(si, ti)))
1481        .collect();
1482    let mut out = Vec::new();
1483    write_uvarint(&mut out, tiles.len() as u64);
1484    let mut prev_min = 0u32;
1485    for (tile, comp) in tiles.iter().zip(&compressed) {
1486        let (min_a, max_a) = tile.leading_range();
1487        write_uvarint(&mut out, (min_a - prev_min) as u64);
1488        write_uvarint(&mut out, (max_a - min_a) as u64);
1489        write_uvarint(&mut out, comp.len() as u64);
1490        prev_min = min_a;
1491    }
1492    for comp in &compressed {
1493        out.extend_from_slice(comp);
1494    }
1495    // Tile-synopsis trailer (FLAG_TILE_SYNOPSIS): per tile, the inclusive min/max
1496    // of the two non-leading columns `(min_b, span_b, min_c, span_c)`, derived
1497    // from each tile's zone map. Appended **after** the tile payloads so a reader
1498    // that predates the flag — which locates tiles by length and stops — never
1499    // reads it (backward-compatible). A reader honoring the flag reads it from the
1500    // section tail. On the (impossible for a built tile) parse failure, emit a
1501    // full range so nothing is ever wrongly pruned.
1502    for ti in 0..n {
1503        let body = index.tile_body(si, ti);
1504        let (min_b, max_b, min_c, max_c) = match crate::triples::TripleBlock::parse(&body) {
1505            Ok(b) => {
1506                let z = b.zone();
1507                (z.min_b, z.max_b, z.min_c, z.max_c)
1508            }
1509            Err(_) => (0, u32::MAX, 0, u32::MAX),
1510        };
1511        write_uvarint(&mut out, min_b as u64);
1512        write_uvarint(&mut out, (max_b - min_b) as u64);
1513        write_uvarint(&mut out, min_c as u64);
1514        write_uvarint(&mut out, (max_c - min_c) as u64);
1515    }
1516    out
1517}
1518
1519/// A parsed v0.2 tile-directory entry: leading-id range plus the tile's byte
1520/// range *within the section payload*.
1521pub(crate) struct TileDirEntry {
1522    pub(crate) min_a: u32,
1523    pub(crate) max_a: u32,
1524    pub(crate) start: u64,
1525    pub(crate) end: u64,
1526}
1527
1528/// One tile's synopsis: inclusive min/max of the two non-leading columns.
1529type TileSynopsis = (u32, u32, u32, u32);
1530
1531/// Parse the **tile-synopsis trailer** (when [`FLAG_TILE_SYNOPSIS`] is set): the
1532/// `num_tiles × (min_b, span_b, min_c, span_c)` records that follow the last tile
1533/// payload, starting at `trailer_start` within `payload`. Returns one synopsis per
1534/// tile, in directory order. `payload` may be just the trailer slice (remote) or
1535/// the whole section (local); `trailer_start` is the offset of the trailer within
1536/// it. A short/garbled trailer yields `None` (the caller keeps `None` synopses —
1537/// pruning simply doesn't fire, never a wrong result).
1538fn parse_tile_synopsis(
1539    payload: &[u8],
1540    trailer_start: usize,
1541    num_tiles: usize,
1542) -> Option<Vec<TileSynopsis>> {
1543    let mut pos = trailer_start;
1544    let take = |pos: &mut usize| -> Option<u32> {
1545        let (v, n) = read_uvarint(payload.get(*pos..)?)?;
1546        *pos += n;
1547        u32::try_from(v).ok()
1548    };
1549    let mut out = Vec::with_capacity(num_tiles.min(payload.len()));
1550    for _ in 0..num_tiles {
1551        let min_b = take(&mut pos)?;
1552        let max_b = min_b.checked_add(take(&mut pos)?)?;
1553        let min_c = take(&mut pos)?;
1554        let max_c = min_c.checked_add(take(&mut pos)?)?;
1555        out.push((min_b, max_b, min_c, max_c));
1556    }
1557    Some(out)
1558}
1559
1560/// Parse a tiled section payload's directory (not the tiles). `bytes` may be a
1561/// **prefix** of the payload (a ranged reader fetches the directory before any
1562/// tile); tile byte ranges are validated against `total_len`, the full payload
1563/// length. Every length is untrusted.
1564fn parse_tile_directory(bytes: &[u8], total_len: u64) -> Result<Vec<TileDirEntry>, FileError> {
1565    let mut pos = 0usize;
1566    let take = |pos: &mut usize| -> Result<u64, FileError> {
1567        let (v, n) = read_uvarint(bytes.get(*pos..).unwrap_or(&[]))
1568            .ok_or(FileError::Container("truncated tile directory"))?;
1569        *pos += n;
1570        Ok(v)
1571    };
1572    let num_tiles = wire_usize(take(&mut pos)?, "tile count too large")?;
1573    let mut entries = Vec::with_capacity(num_tiles.min(bytes.len()));
1574    let mut prev_min = 0u32;
1575    let mut lens = Vec::with_capacity(num_tiles.min(bytes.len()));
1576    // Leading ids are u32. A delta or span past that, or a sum that leaves
1577    // the id space, is corrupt: `as u32` and `wrapping_add` used to turn it
1578    // into some other, plausible id range and route lookups to the wrong tile.
1579    let id = |v: u64| u32::try_from(v).map_err(|_| FileError::Container("tile id out of range"));
1580    for _ in 0..num_tiles {
1581        let dmin = id(take(&mut pos)?)?;
1582        let span = id(take(&mut pos)?)?;
1583        let len = take(&mut pos)?;
1584        let min_a = prev_min
1585            .checked_add(dmin)
1586            .ok_or(FileError::Container("tile id out of range"))?;
1587        let max_a = min_a
1588            .checked_add(span)
1589            .ok_or(FileError::Container("tile id out of range"))?;
1590        entries.push(TileDirEntry {
1591            min_a,
1592            max_a,
1593            start: 0,
1594            end: 0,
1595        });
1596        lens.push(len);
1597        prev_min = min_a;
1598    }
1599    let mut start = pos as u64;
1600    for (e, len) in entries.iter_mut().zip(lens) {
1601        let end = start
1602            .checked_add(len)
1603            .filter(|&e| e <= total_len)
1604            .ok_or(FileError::Container("tile overruns section"))?;
1605        e.start = start;
1606        e.end = end;
1607        start = end;
1608    }
1609    Ok(entries)
1610}
1611
1612/// Fetch and parse a remote tiled section's directory: read a small prefix and
1613/// grow it geometrically until the directory parses, never fetching past the
1614/// section. A directory that still fails on the whole section is corrupt.
1615pub(crate) fn read_tile_directory_ranged<R: RangeReader + ?Sized>(
1616    reader: &R,
1617    section: ByteRange,
1618) -> Result<Vec<TileDirEntry>, FileError> {
1619    let total = section.len;
1620    let mut prefetch = 4096u64.min(total);
1621    loop {
1622        let prefix = reader.read_at(section.offset, prefetch)?;
1623        match parse_tile_directory(&prefix, total) {
1624            Ok(dir) => return Ok(dir),
1625            Err(_) if prefetch < total => prefetch = prefetch.saturating_mul(2).min(total),
1626            Err(e) => return Err(e),
1627        }
1628    }
1629}
1630
1631/// Fetch and parse a remote section's **tile-synopsis trailer** (only when the
1632/// header's [`FLAG_TILE_SYNOPSIS`] is set): one targeted range read of the bytes
1633/// past the last tile, parsed into one synopsis per tile (directory order). A
1634/// missing/short/garbled trailer degrades to all-`None` — pruning simply doesn't
1635/// fire, never a wrong result. The directory gives the trailer's start (the last
1636/// tile's end).
1637pub(crate) fn read_tile_synopsis_ranged<R: RangeReader + ?Sized>(
1638    reader: &R,
1639    section: ByteRange,
1640    dir: &[TileDirEntry],
1641) -> Vec<Option<TileSynopsis>> {
1642    let n = dir.len();
1643    let none = vec![None; n];
1644    let trailer_start = dir.iter().map(|e| e.end).max().unwrap_or(0);
1645    let total = section.len;
1646    if n == 0 || trailer_start >= total {
1647        return none; // no trailer bytes present
1648    }
1649    // Four varints per tile, each a u32 (at most 5 bytes): never read more,
1650    // whatever the section claims. The trailer is the section's tail, so a
1651    // hostile section length used to make this one read as large as it said.
1652    let max_trailer = (n as u64).saturating_mul(4 * 5);
1653    let trailer_len = (total - trailer_start).min(max_trailer);
1654    let Some(offset) = section.offset.checked_add(trailer_start) else {
1655        return none;
1656    };
1657    let Ok(bytes) = reader.read_at(offset, trailer_len) else {
1658        return none;
1659    };
1660    match parse_tile_synopsis(&bytes, 0, n) {
1661        Some(v) => v.into_iter().map(Some).collect(),
1662        None => none,
1663    }
1664}
1665
1666/// Per-tile absolute file ranges of each permutation section, for provenance.
1667/// A malformed directory yields an empty section (provenance degrades, queries
1668/// are unaffected).
1669fn tile_file_ranges(
1670    index_bytes: &[u8],
1671    container_offset: u64,
1672    section_ranges: &[ByteRange; NUM_PERMS],
1673) -> [Vec<(u32, u32, ByteRange)>; NUM_PERMS] {
1674    let mut out: [Vec<(u32, u32, ByteRange)>; NUM_PERMS] = Default::default();
1675    for (section, range) in out.iter_mut().zip(section_ranges) {
1676        // A permutation the file does not carry has a zeroed range. Its
1677        // `offset - container_offset` underflows, and before this guard it
1678        // panicked the whole `open_ranged` path on the first lean file.
1679        if range.len == 0 || range.offset < container_offset {
1680            continue;
1681        }
1682        let Some(payload) = checked_slice(index_bytes, range.offset - container_offset, range.len)
1683        else {
1684            continue;
1685        };
1686        if let Ok(dir) = parse_tile_directory(payload, payload.len() as u64) {
1687            *section = dir
1688                .into_iter()
1689                .map(|e| {
1690                    (
1691                        e.min_a,
1692                        e.max_a,
1693                        ByteRange {
1694                            offset: range.offset + e.start,
1695                            len: (e.end - e.start),
1696                        },
1697                    )
1698                })
1699                .collect();
1700        }
1701    }
1702    out
1703}
1704
1705/// Decode a tiled section payload into `(min_a, max_a, uncompressed tile)`
1706/// triples.
1707fn decode_tiled_section(payload: &[u8], codec: u8) -> Result<Vec<(u32, u32, Vec<u8>)>, FileError> {
1708    parse_tile_directory(payload, payload.len() as u64)?
1709        .into_iter()
1710        .map(|e| {
1711            Ok((
1712                e.min_a,
1713                e.max_a,
1714                decompress(codec, &payload[e.start as usize..e.end as usize])?,
1715            ))
1716        })
1717        .collect()
1718}
1719
1720/// Decode the index container: one raw tiled section payload per permutation
1721/// the file carries (`perms`, from the header — [`PermSet::ALL`] for every file
1722/// written before the mask existed), each tile compressed individually.
1723///
1724/// The section count is checked against `perms.len()`, so a file whose header
1725/// and container disagree is rejected rather than silently short-read. A reader
1726/// that does not know about lean files at all reaches the same conclusion by a
1727/// different route: it passes six, the container says three, and this errors —
1728/// which is why a lean file cannot be misread as an empty one.
1729fn decode_index_container(
1730    bytes: &[u8],
1731    codec: u8,
1732    perms: PermSet,
1733) -> Result<GraphIndex, FileError> {
1734    let isecs = decode_container(bytes, CODEC_NONE)?;
1735    if isecs.len() != perms.len() {
1736        return Err(FileError::Container(
1737            "index container section count does not match the header permutation mask",
1738        ));
1739    }
1740    decode_index_sections(isecs, codec, perms)
1741}
1742
1743fn decode_index_sections(
1744    mut isecs: Vec<Vec<u8>>,
1745    codec: u8,
1746    perms: PermSet,
1747) -> Result<GraphIndex, FileError> {
1748    let mut sections: [Vec<(u32, u32, Vec<u8>)>; NUM_PERMS] = Default::default();
1749    for (perm, sec) in perms.iter().zip(isecs.iter_mut()) {
1750        sections[perm.section_index()] = decode_tiled_section(sec, codec)?;
1751    }
1752    Ok(GraphIndex::from_tiles(sections, perms))
1753}
1754
1755/// Decode the file's **root** index container (the default graph): the
1756/// permutation sections, plus — on a quad-index file — the trailing seal, which
1757/// is checked and set aside. Returns the seal's digest of the QUAD_INDEX
1758/// section when there is one.
1759fn decode_root_index_container(
1760    bytes: &[u8],
1761    header: &Header,
1762) -> Result<(GraphIndex, Option<[u8; 16]>), FileError> {
1763    if !header.has_quad_index() {
1764        return Ok((
1765            decode_index_container(bytes, header.block_codec, header.perms)?,
1766            None,
1767        ));
1768    }
1769    let mut isecs = decode_container(bytes, CODEC_NONE)?;
1770    if isecs.len() != header.index_container_sections() {
1771        return Err(FileError::Container(
1772            "index container section count does not match the header permutation mask",
1773        ));
1774    }
1775    let seal = isecs.pop().unwrap_or_default();
1776    let digest = crate::quad::parse_seal(&seal)?;
1777    Ok((
1778        decode_index_sections(isecs, header.block_codec, header.perms)?,
1779        Some(digest),
1780    ))
1781}
1782
1783/// The named-graph layout a header announces must be the one its directory
1784/// holds: the quad flag with a QUAD_INDEX section and no NAMED_GRAPHS section,
1785/// or no flag and no QUAD_INDEX section. Anything else is a corrupt (or a
1786/// hand-edited) header, refused before either layout is read.
1787fn check_named_layout(header: &Header) -> Result<(), FileError> {
1788    let quad = header.quad_index_section().is_some();
1789    if header.has_quad_index() {
1790        if !quad {
1791            return Err(FileError::Container(
1792                "quad-index flag set but the file has no quad-index section",
1793            ));
1794        }
1795        if header.named_graphs_len > 0 {
1796            return Err(FileError::Container(
1797                "a file cannot carry both a quad index and a named-graphs section",
1798            ));
1799        }
1800        crate::quad::check_perms(header.perms)?;
1801    } else if quad {
1802        return Err(FileError::Container(
1803            "quad-index section present but the header does not flag it",
1804        ));
1805    }
1806    Ok(())
1807}
1808
1809fn container_section_payload_ranges(
1810    bytes: &[u8],
1811    container_offset: u64,
1812    expected_sections: usize,
1813) -> Result<Vec<ByteRange>, FileError> {
1814    let (section_count, mut pos) =
1815        read_uvarint(bytes).ok_or(FileError::Container("truncated count"))?;
1816    let section_count = usize::try_from(section_count)
1817        .map_err(|_| FileError::Container("section count too large"))?;
1818    if section_count != expected_sections {
1819        return Err(FileError::Container("unexpected section count"));
1820    }
1821
1822    let mut ranges = Vec::with_capacity(section_count);
1823    for _ in 0..section_count {
1824        let remaining = bytes
1825            .get(pos..)
1826            .ok_or(FileError::Container("truncated length"))?;
1827        let (payload_len, used) =
1828            read_uvarint(remaining).ok_or(FileError::Container("truncated length"))?;
1829        pos = pos
1830            .checked_add(used)
1831            .ok_or(FileError::Container("section range overflows"))?;
1832        let payload_len_usize = usize::try_from(payload_len)
1833            .map_err(|_| FileError::Container("section length too large"))?;
1834        let payload_end = pos
1835            .checked_add(payload_len_usize)
1836            .ok_or(FileError::Container("section range overflows"))?;
1837        if payload_end > bytes.len() {
1838            return Err(FileError::Container("section overruns buffer"));
1839        }
1840        ranges.push(ByteRange {
1841            offset: checked_end(container_offset, pos as u64)?,
1842            len: payload_len,
1843        });
1844        pos = payload_end;
1845    }
1846
1847    Ok(ranges)
1848}
1849
1850/// The permutation payload ranges of an index container holding `sections`
1851/// sections (the permutations, then — on a quad-index root — the seal, which
1852/// is not a permutation and is left out).
1853fn decode_index_section_ranges(
1854    bytes: &[u8],
1855    container_offset: u64,
1856    perms: PermSet,
1857    sections: usize,
1858) -> Result<[ByteRange; NUM_PERMS], FileError> {
1859    let ranges = container_section_payload_ranges(bytes, container_offset, sections)?;
1860    let mut out = [ByteRange { offset: 0, len: 0 }; NUM_PERMS];
1861    for (perm, range) in perms.iter().zip(ranges) {
1862        out[perm.section_index()] = range;
1863    }
1864    Ok(out)
1865}
1866
1867fn read_uvarint_at<R: RangeReader + ?Sized>(
1868    reader: &R,
1869    absolute_offset: u64,
1870    container_end: u64,
1871) -> Result<(u64, u64), FileError> {
1872    if absolute_offset >= container_end {
1873        return Err(FileError::Container("truncated container varint"));
1874    }
1875    let remaining = container_end - absolute_offset;
1876    let probe_len = remaining.min(10);
1877    let bytes = reader.read_at(absolute_offset, probe_len)?;
1878    read_uvarint(&bytes)
1879        .map(|(value, used)| (value, used as u64))
1880        .ok_or(FileError::Container("truncated container varint"))
1881}
1882
1883/// Locate one section's payload byte range inside a remote container, walking
1884/// only the (tiny) varint framing — no payload bytes are fetched.
1885pub(crate) fn locate_container_section_ranged<R: RangeReader + ?Sized>(
1886    reader: &R,
1887    container_offset: u64,
1888    container_len: u64,
1889    section_index: usize,
1890    expected_sections: u64,
1891) -> Result<ByteRange, FileError> {
1892    let container_end = checked_end(container_offset, container_len)?;
1893    let (section_count, used) = read_uvarint_at(reader, container_offset, container_end)?;
1894    if section_count != expected_sections {
1895        return Err(FileError::Container("unexpected container section count"));
1896    }
1897    if section_index >= section_count as usize {
1898        return Err(FileError::Container(
1899            "container section index out of bounds",
1900        ));
1901    }
1902
1903    let mut pos = checked_end(container_offset, used)?;
1904    for i in 0..section_count as usize {
1905        let (payload_len, len_used) = read_uvarint_at(reader, pos, container_end)?;
1906        pos = checked_end(pos, len_used)?;
1907        let payload_end = checked_end(pos, payload_len)?;
1908        if payload_end > container_end {
1909            return Err(FileError::Container("section overruns buffer"));
1910        }
1911        if i == section_index {
1912            return Ok(ByteRange {
1913                offset: pos,
1914                len: payload_len,
1915            });
1916        }
1917        pos = payload_end;
1918    }
1919    Err(FileError::Container("container section not found"))
1920}
1921
1922/// Open one index container (six tiled permutation sections) **lazily** over a
1923/// range reader: fetch each section's tile directory (and synopsis trailer,
1924/// when the file has one) but no tile payloads — tiles fault in on first scan.
1925/// Returns the index plus its section and tile file ranges (provenance).
1926///
1927/// This is the machinery `open_ranged_lazy` always used for the default
1928/// graph's container at `root_dir_offset`; a named graph's container is the
1929/// same format at a different offset, so the lazy named-graphs path opens a
1930/// LARGE graph through this too instead of decoding it resident.
1931#[allow(clippy::type_complexity)]
1932fn open_index_container_lazy(
1933    reader: &std::sync::Arc<dyn RangeReader + Send + Sync>,
1934    container: ByteRange,
1935    block_codec: u8,
1936    has_synopsis: bool,
1937    read_concurrency: usize,
1938    perms: PermSet,
1939    sections: usize,
1940) -> Result<
1941    (
1942        GraphIndex,
1943        [ByteRange; NUM_PERMS],
1944        [Vec<(u32, u32, ByteRange)>; NUM_PERMS],
1945    ),
1946    FileError,
1947> {
1948    let mut index_section_ranges = [ByteRange { offset: 0, len: 0 }; NUM_PERMS];
1949    let mut tile_ranges: [Vec<(u32, u32, ByteRange)>; NUM_PERMS] = Default::default();
1950    #[allow(clippy::type_complexity)]
1951    let mut directories: [Vec<(u32, u32, Option<TileSynopsis>)>; NUM_PERMS] = Default::default();
1952    for (pos, perm) in perms.iter().enumerate() {
1953        let si = perm.section_index();
1954        let section = locate_container_section_ranged(
1955            reader,
1956            container.offset,
1957            container.len,
1958            pos,
1959            sections as u64,
1960        )?;
1961        index_section_ranges[si] = section;
1962        let dir = read_tile_directory_ranged(reader, section)?;
1963        // Tile synopses (one extra small tail read per section) let a routed
1964        // scan prune a tile by a bound secondary component before faulting it.
1965        let syn = if has_synopsis {
1966            read_tile_synopsis_ranged(reader, section, &dir)
1967        } else {
1968            vec![None; dir.len()]
1969        };
1970        directories[si] = dir
1971            .iter()
1972            .zip(syn)
1973            .map(|(e, s)| (e.min_a, e.max_a, s))
1974            .collect();
1975        tile_ranges[si] = dir
1976            .into_iter()
1977            .map(|e| {
1978                (
1979                    e.min_a,
1980                    e.max_a,
1981                    ByteRange {
1982                        offset: section.offset + e.start,
1983                        len: (e.end - e.start),
1984                    },
1985                )
1986            })
1987            .collect();
1988    }
1989
1990    // The loader fetches and decompresses one tile per call; the bulk
1991    // loader serves multi-tile scans by coalescing adjacent tile ranges
1992    // into single range reads (tiles are back-to-back in their section,
1993    // so a full-section scan is typically one request).
1994    //
1995    // Either loader returns `None` (a failed load: the index marks itself
1996    // incomplete and a reset retries) unless the tile arrived whole and its
1997    // zone map parses. Without the length check, a short read of a raw
1998    // (uncompressed) tile decoded as a shorter tile and silently lost rows.
1999    let codec = block_codec;
2000    let loader_ranges = tile_ranges.clone();
2001    let loader_reader = reader.clone();
2002    let loader: crate::index::TileLoader = Box::new(move |si, ti| {
2003        let (_, _, range) = loader_ranges.get(si)?.get(ti)?;
2004        let bytes = loader_reader.read_at(range.offset, range.len).ok()?;
2005        if bytes.len() as u64 != range.len {
2006            return None;
2007        }
2008        checked_tile(codec, &bytes)
2009    });
2010    let bulk_ranges = tile_ranges.clone();
2011    let bulk_reader = reader.clone();
2012    let bulk: crate::index::TileBulkLoader = Box::new(move |si, tis| {
2013        let section = bulk_ranges.get(si)?;
2014        let want: Option<Vec<ByteRange>> = tis
2015            .iter()
2016            .map(|&ti| section.get(ti).map(|&(_, _, r)| r))
2017            .collect();
2018        let blobs = read_coalesced(bulk_reader.as_ref(), &want?, TILE_COALESCE_GAP)?;
2019        blobs.iter().map(|b| checked_tile(codec, b)).collect()
2020    });
2021    let mut index =
2022        GraphIndex::from_remote_directories(directories, perms, loader).with_bulk_loader(bulk);
2023    // Per-tile encoded lengths (from the directory) feed the join planner's
2024    // fatness gates — free here, unavailable later without a fetch.
2025    index.set_tile_lens(std::array::from_fn(|si| {
2026        tile_ranges[si]
2027            .iter()
2028            .map(|&(_, _, r)| r.len.min(u32::MAX as u64) as u32)
2029            .collect()
2030    }));
2031    // The reader's fan-out widens the planner's remote probe budget: a
2032    // desktop/CLI reader overlapping 16 range reads probes far more cheaply
2033    // than a phone's serial sync-XHR path.
2034    index.set_read_concurrency(read_concurrency);
2035    Ok((index, index_section_ranges, tile_ranges))
2036}
2037
2038/// A fetched tile, decompressed, or `None` when it does not decompress or its
2039/// zone map does not parse: a lazy tile load fails on the first scan that
2040/// touches it, instead of a corrupt tile reading as an empty or partial one.
2041fn checked_tile(codec: u8, bytes: &[u8]) -> Option<Vec<u8>> {
2042    let tile = decompress(codec, bytes).ok()?;
2043    crate::triples::TripleBlock::parse(&tile).ok()?;
2044    Some(tile)
2045}
2046
2047/// Serialize a complete `.rete` file image from a dictionary, index, and an
2048/// (optionally empty) encoded pyramid-meta section. `pyramid_levels` records the
2049/// number of dendrogram rounds the pyramid spans (0 if no pyramid).
2050///
2051/// The image is format `0x06`, the one layout every writer produces (the
2052/// legacy `0x05` image is [`write_file_v05`]).
2053pub fn write_file(
2054    dict: &Dictionary,
2055    index: &GraphIndex,
2056    has_quads: bool,
2057    pyramid_meta: &[u8],
2058    pyramid_levels: u16,
2059) -> Vec<u8> {
2060    write_dataset(dict, index, &[], has_quads, pyramid_meta, pyramid_levels)
2061}
2062
2063/// The legacy `0x05` image of [`write_file`]. The layout of the first stable
2064/// generation, kept for the tests that pin its byte layout, for the in-RAM
2065/// builders that hand their `0x05` image to the one `0x06` finish step, and for
2066/// `RETE_WRITE_FORMAT=5`.
2067#[doc(hidden)]
2068pub fn write_file_v05(
2069    dict: &Dictionary,
2070    index: &GraphIndex,
2071    has_quads: bool,
2072    pyramid_meta: &[u8],
2073    pyramid_levels: u16,
2074) -> Vec<u8> {
2075    write_dataset_v05(dict, index, &[], has_quads, pyramid_meta, pyramid_levels)
2076}
2077
2078/// A finished `0x05` image as the `0x06` file the public writers return (the
2079/// image is untouched under `RETE_WRITE_FORMAT=5`).
2080fn into_v06(image: Vec<u8>) -> Vec<u8> {
2081    crate::build_record::finish_in_ram(image, None)
2082        .unwrap_or_else(|e| panic!("writing format 0x06: {e}"))
2083}
2084
2085/// Encode an index container (v0.2): one raw tiled section payload per
2086/// permutation the index carries, in [`crate::index::ALL_PERMS`] order, tiles
2087/// compressed individually with `codec`. A three-permutation index writes three
2088/// sections — not six with three empty, which would be indistinguishable from
2089/// an empty graph to a reader that does not check the header mask.
2090pub(crate) fn encode_index_container(index: &GraphIndex, codec: u8) -> Vec<u8> {
2091    encode_index_container_sealed(index, codec, None)
2092}
2093
2094/// [`encode_index_container`] with an optional trailing **seal** section —
2095/// the root container of a quad-index file (see `quad.rs`). The seal is what
2096/// makes every reader that predates the quad index refuse the file: they all
2097/// check the container's section count before reading a payload.
2098fn encode_index_container_sealed(index: &GraphIndex, codec: u8, seal: Option<&[u8]>) -> Vec<u8> {
2099    let payloads: Vec<Vec<u8>> = index
2100        .perms()
2101        .iter()
2102        .map(|perm| encode_tiled_section(index, perm.section_index(), codec))
2103        .collect();
2104    let mut refs: Vec<&[u8]> = payloads.iter().map(|p| p.as_slice()).collect();
2105    if let Some(seal) = seal {
2106        refs.push(seal);
2107    }
2108    encode_container(&refs, CODEC_NONE)
2109}
2110
2111/// Encode the named-graphs section: each graph as `(iri, permutation container)`.
2112fn encode_named_graphs(named: &[(String, GraphIndex)], codec: u8) -> Vec<u8> {
2113    let mut out = Vec::new();
2114    write_uvarint(&mut out, named.len() as u64);
2115    for (iri, index) in named {
2116        write_uvarint(&mut out, iri.len() as u64);
2117        out.extend_from_slice(iri.as_bytes());
2118        let container = encode_index_container(index, codec);
2119        write_uvarint(&mut out, container.len() as u64);
2120        out.extend_from_slice(&container);
2121    }
2122    out
2123}
2124
2125fn decode_named_graphs(
2126    bytes: &[u8],
2127    codec: u8,
2128    perms: PermSet,
2129) -> Result<Vec<(String, GraphIndex)>, FileError> {
2130    let (n, mut pos) = read_uvarint(bytes).ok_or(FileError::Container("truncated graph count"))?;
2131    // Bounds-checked slice within this (already bounded) section. Lengths read
2132    // below are untrusted, so every range is validated before indexing.
2133    let bound = |start: usize, len: u64| -> Result<usize, FileError> {
2134        usize::try_from(len)
2135            .ok()
2136            .and_then(|len| start.checked_add(len))
2137            .filter(|&e| e <= bytes.len())
2138            .ok_or(FileError::Container("named-graph field overruns buffer"))
2139    };
2140    let n = wire_usize(n, "named-graph count too large")?;
2141    let mut out = Vec::with_capacity(n.min(bytes.len()));
2142    for _ in 0..n {
2143        let (ilen, u1) = read_uvarint(bytes.get(pos..).unwrap_or(&[]))
2144            .ok_or(FileError::Container("truncated iri len"))?;
2145        pos += u1;
2146        let iend = bound(pos, ilen)?;
2147        let iri = String::from_utf8_lossy(&bytes[pos..iend]).into_owned();
2148        pos = iend;
2149        let (clen, u2) = read_uvarint(bytes.get(pos..).unwrap_or(&[]))
2150            .ok_or(FileError::Container("truncated container len"))?;
2151        pos += u2;
2152        let cend = bound(pos, clen)?;
2153        let index = decode_index_container(&bytes[pos..cend], codec, perms)?;
2154        // Resident, so the count is a pass over tile headers; the planner
2155        // sizes this graph's patterns by it, not by the file's total.
2156        index.set_size_hint(u64::from(index.triple_count()));
2157        out.push((iri, index));
2158        pos = cend;
2159    }
2160    Ok(out)
2161}
2162
2163/// Serialize a full RDF *dataset*: the default-graph index plus zero or more
2164/// named graphs `(iri, index)`, all sharing one dictionary. Format `0x06`; named
2165/// graphs, given per graph here, are stored as the quad index (`0x06` has no
2166/// other layout).
2167pub fn write_dataset(
2168    dict: &Dictionary,
2169    default_index: &GraphIndex,
2170    named: &[(String, GraphIndex)],
2171    has_quads: bool,
2172    pyramid_meta: &[u8],
2173    pyramid_levels: u16,
2174) -> Vec<u8> {
2175    write_dataset_with_metadata(
2176        dict,
2177        default_index,
2178        named,
2179        has_quads,
2180        pyramid_meta,
2181        pyramid_levels,
2182        &[],
2183        &[],
2184    )
2185}
2186
2187/// The legacy `0x05` image of [`write_dataset`] (see [`write_file_v05`]).
2188#[doc(hidden)]
2189pub fn write_dataset_v05(
2190    dict: &Dictionary,
2191    default_index: &GraphIndex,
2192    named: &[(String, GraphIndex)],
2193    has_quads: bool,
2194    pyramid_meta: &[u8],
2195    pyramid_levels: u16,
2196) -> Vec<u8> {
2197    write_dataset_with_metadata_v05(
2198        dict,
2199        default_index,
2200        named,
2201        has_quads,
2202        pyramid_meta,
2203        pyramid_levels,
2204        &[],
2205        &[],
2206    )
2207}
2208
2209/// Serialize a dictionary to its on-file container bytes (4 front-coded, chunked
2210/// sections). Exposed so a low-RAM build can serialize **and drop** the live
2211/// `Dictionary` before building the permutation index — the index build works on
2212/// id-triples and never needs the dictionary.
2213pub(crate) fn encode_dict_container(dict: &Dictionary, codec: u8) -> Vec<u8> {
2214    let raw_sections = dict.sections();
2215    let dict_payloads: Vec<Vec<u8>> = raw_sections
2216        .iter()
2217        .map(|raw| encode_chunked_dict_section(raw, codec))
2218        .collect();
2219    encode_container(
2220        &[
2221            dict_payloads[0].as_slice(),
2222            dict_payloads[1].as_slice(),
2223            dict_payloads[2].as_slice(),
2224            dict_payloads[3].as_slice(),
2225        ],
2226        CODEC_NONE,
2227    )
2228}
2229
2230/// Serialize a dataset with an opaque **metadata** payload occupying the file's
2231/// metadata section (the application layer defines its meaning — the CLI stores a
2232/// JSON Dataset Card there). The section sits immediately after the header and
2233/// before the dictionary, so `metadata_offset` stays at `HEADER_LEN` and every
2234/// downstream section shifts by `metadata.len()`. The payload is folded into the
2235/// `content_hash`, so `verify` covers it and it is tamper-evident.
2236///
2237/// Passing an empty `metadata` is byte-identical to [`write_dataset`]: the section
2238/// is omitted (`metadata_len = 0`, `dictionary_offset = HEADER_LEN`) and the hash
2239/// is computed over exactly the same parts (a zero-length hash update is a no-op).
2240#[allow(clippy::too_many_arguments)]
2241pub fn write_dataset_with_metadata(
2242    dict: &Dictionary,
2243    default_index: &GraphIndex,
2244    named: &[(String, GraphIndex)],
2245    has_quads: bool,
2246    pyramid_meta: &[u8],
2247    pyramid_levels: u16,
2248    metadata: &[u8],
2249    text_index: &[u8],
2250) -> Vec<u8> {
2251    into_v06(write_dataset_with_metadata_v05(
2252        dict,
2253        default_index,
2254        named,
2255        has_quads,
2256        pyramid_meta,
2257        pyramid_levels,
2258        metadata,
2259        text_index,
2260    ))
2261}
2262
2263/// The legacy `0x05` image of [`write_dataset_with_metadata`] (see
2264/// [`write_file_v05`]).
2265#[doc(hidden)]
2266#[allow(clippy::too_many_arguments)]
2267pub fn write_dataset_with_metadata_v05(
2268    dict: &Dictionary,
2269    default_index: &GraphIndex,
2270    named: &[(String, GraphIndex)],
2271    has_quads: bool,
2272    pyramid_meta: &[u8],
2273    pyramid_levels: u16,
2274    metadata: &[u8],
2275    text_index: &[u8],
2276) -> Vec<u8> {
2277    let codec = writer_codec();
2278    let dict_container = encode_dict_container(dict, codec);
2279    // This entry point takes a finished section, so it reads the section's own
2280    // scope block to pick the directory kind (6, or 11 for a partial index) and
2281    // the header's named-graphs bit.
2282    let scope = (!text_index.is_empty())
2283        .then(|| crate::text_index::TextIndex::from_section(text_index, codec).ok())
2284        .flatten()
2285        .map(|ti| ti.scope().clone());
2286    let text_index_partial = scope
2287        .as_ref()
2288        .is_some_and(crate::text_index::TextIndexScope::restricts_predicates);
2289    let text_index_named = scope
2290        .as_ref()
2291        .is_some_and(crate::text_index::TextIndexScope::covers_named_graphs);
2292    write_dataset_from_parts(
2293        &dict_container,
2294        dict.term_count() as u64,
2295        default_index,
2296        NamedPart::PerGraph(named),
2297        has_quads,
2298        dict.has_quoted_triples(),
2299        pyramid_meta,
2300        pyramid_levels,
2301        metadata,
2302        text_index,
2303        text_index_partial,
2304        text_index_named,
2305        codec,
2306    )
2307}
2308
2309/// How the named graphs reach the writer: one index per graph (the
2310/// NAMED_GRAPHS section, kind 5), or an encoded quad index (kind 10).
2311pub(crate) enum NamedPart<'a> {
2312    PerGraph(&'a [(String, GraphIndex)]),
2313    Quad(&'a crate::quad::EncodedQuadIndex),
2314}
2315
2316/// Assemble the final file image from an **already-serialized** dictionary
2317/// container (so the caller can drop the live `Dictionary` before calling this)
2318/// plus the permutation index and optional sections. The byte output is identical
2319/// to serializing the dictionary inline.
2320#[allow(clippy::too_many_arguments)]
2321pub(crate) fn write_dataset_from_parts(
2322    dict_container: &[u8],
2323    term_count: u64,
2324    default_index: &GraphIndex,
2325    named: NamedPart<'_>,
2326    has_quads: bool,
2327    has_quoted_triples: bool,
2328    pyramid_meta: &[u8],
2329    pyramid_levels: u16,
2330    metadata: &[u8],
2331    text_index: &[u8],
2332    text_index_partial: bool,
2333    text_index_named: bool,
2334    codec: u8,
2335) -> Vec<u8> {
2336    // The named graphs: either the per-graph section (kind 5) or the quad index
2337    // (kind 10), whose digest the root container's seal carries. An empty quad
2338    // index is written as no named graphs at all.
2339    let (named_section, quad, named_quads) = match named {
2340        NamedPart::PerGraph(named) => (
2341            if named.is_empty() {
2342                Vec::new()
2343            } else {
2344                encode_named_graphs(named, codec)
2345            },
2346            None,
2347            named
2348                .iter()
2349                .map(|(_, idx)| idx.triple_count() as u64)
2350                .sum::<u64>(),
2351        ),
2352        NamedPart::Quad(q) if q.graph_count == 0 => (Vec::new(), None, 0),
2353        NamedPart::Quad(q) => (Vec::new(), Some(q), q.quad_count),
2354    };
2355    let seal = quad.map(|q| crate::quad::encode_seal(crate::quad::digest(&q.bytes)));
2356    let index_container = encode_index_container_sealed(default_index, codec, seal.as_deref());
2357    // A predicate-restricted index (kind 11) is outside the content hash, like
2358    // kinds 8 and 9, and starts with its own digest instead: a reader older
2359    // than 0.4.0 does not see kind 11, so a hashed one would make its `verify`
2360    // call every such file corrupt. A kind-6 index is hashed, as it always was.
2361    let text_index_partial = text_index_partial && !text_index.is_empty();
2362    let digested;
2363    let text_index: &[u8] = if text_index_partial {
2364        digested = crate::text_index::with_partial_digest(text_index);
2365        &digested
2366    } else {
2367        text_index
2368    };
2369
2370    // The metadata section (if any) sits between the header and the dictionary,
2371    // so the dictionary — and everything after it — shifts forward by its length.
2372    let meta_section_len = metadata.len() as u64;
2373    let dict_offset = HEADER_LEN as u64 + meta_section_len;
2374    let dict_len = dict_container.len() as u64;
2375    let index_offset = dict_offset + dict_len;
2376    let index_len = index_container.len() as u64;
2377    let pyr_offset = index_offset + index_len;
2378    let pyr_len = pyramid_meta.len() as u64;
2379    // Optional full-text index between the pyramid and the named graphs.
2380    let text_offset = pyr_offset + pyr_len;
2381    let text_len = text_index.len() as u64;
2382    let named_offset = text_offset + text_len;
2383    let named_len = named_section.len() as u64;
2384    // The quad index sits where the named-graphs section would (the two are
2385    // exclusive).
2386    let quad_len = quad.map_or(0, |q| q.bytes.len() as u64);
2387
2388    // Hash parts in physical order, with the metadata payload prepended when
2389    // present. Omitting it entirely (rather than hashing an empty slice) keeps the
2390    // no-metadata output's hash byte-identical to the pre-metadata writer.
2391    // `verify()` rebuilds this exact list from the header — any section added
2392    // here must be added there too (and covered by a tamper test).
2393    let mut parts: Vec<&[u8]> = Vec::with_capacity(5);
2394    if meta_section_len > 0 {
2395        parts.push(metadata);
2396    }
2397    parts.push(dict_container);
2398    parts.push(&index_container);
2399    parts.push(pyramid_meta);
2400    if text_len > 0 && !text_index_partial {
2401        parts.push(text_index);
2402    }
2403    if named_len > 0 {
2404        parts.push(&named_section);
2405    }
2406
2407    // Length of the trailing schema-pyramid block (0 if none), so a reader can
2408    // fetch just that block for an index/dictionary/summary-free Tier-0 read.
2409    let schema_meta_len = crate::meta::schema_block_len(pyramid_meta);
2410
2411    // The QUAD_INDEX section is not a hash part of its own: the seal inside the
2412    // index container carries its digest, so the content hash covers it — and a
2413    // reader that predates it computes the same hash over the same parts.
2414    let header = Header {
2415        version: crate::header::LEGACY_FORMAT_VERSION,
2416        flags: FLAG_TILE_SYNOPSIS
2417            | if has_quads { FLAG_HAS_QUADS } else { 0 }
2418            | if quad.is_some() {
2419                crate::header::FLAG_QUAD_INDEX
2420            } else {
2421                0
2422            }
2423            | if has_quoted_triples {
2424                FLAG_HAS_QUOTED_TRIPLES
2425            } else {
2426                0
2427            }
2428            | if text_len > 0 && text_index_named {
2429                crate::header::FLAG_TEXT_INDEX_NAMED_GRAPHS
2430            } else {
2431                0
2432            },
2433        metadata_offset: HEADER_LEN as u64,
2434        metadata_len: meta_section_len,
2435        dictionary_offset: dict_offset,
2436        dictionary_len: dict_len,
2437        root_dir_offset: index_offset,
2438        root_dir_len: index_len,
2439        pyramid_meta_offset: if pyr_len > 0 { pyr_offset } else { 0 },
2440        pyramid_meta_len: pyr_len,
2441        dict_codec: codec,
2442        block_codec: codec,
2443        pyramid_levels,
2444        id_widths: 0,
2445        // Every container in the file — default graph and named graphs alike —
2446        // is written from an index built with the same set, so the mask is a
2447        // file-level fact and the default index is its source of truth.
2448        perms: default_index.perms(),
2449        quad_count: default_index.triple_count() as u64 + named_quads,
2450        term_count,
2451        content_hash: content_hash(&parts),
2452        named_graphs_offset: if named_len > 0 { named_offset } else { 0 },
2453        named_graphs_len: named_len,
2454        schema_meta_len,
2455        text_index_offset: if text_len > 0 { text_offset } else { 0 },
2456        text_index_len: text_len,
2457        text_index_partial: text_len > 0 && text_index_partial,
2458        build_info_offset: 0,
2459        build_info_len: 0,
2460        extra_sections: if quad_len > 0 {
2461            vec![crate::header::Section {
2462                kind: crate::header::SectionKind::QuadIndex,
2463                flags: 0,
2464                offset: named_offset,
2465                length: quad_len,
2466            }]
2467        } else {
2468            Vec::new()
2469        },
2470    };
2471
2472    let mut out = Vec::with_capacity(
2473        HEADER_LEN
2474            + metadata.len()
2475            + dict_container.len()
2476            + index_container.len()
2477            + pyramid_meta.len()
2478            + text_index.len()
2479            + named_section.len()
2480            + quad_len as usize
2481            + MAGIC.len(),
2482    );
2483    out.extend_from_slice(&header.to_bytes());
2484    if meta_section_len > 0 {
2485        out.extend_from_slice(metadata);
2486    }
2487    out.extend_from_slice(dict_container);
2488    out.extend_from_slice(&index_container);
2489    out.extend_from_slice(pyramid_meta);
2490    if text_len > 0 {
2491        out.extend_from_slice(text_index);
2492    }
2493    if named_len > 0 {
2494        out.extend_from_slice(&named_section);
2495    }
2496    if let Some(q) = quad {
2497        out.extend_from_slice(&q.bytes);
2498    }
2499    out.extend_from_slice(&MAGIC); // footer marker
2500    out
2501}
2502
2503/// `rdf:type` — the predicate that assigns a class to a resource.
2504pub const RDF_TYPE: &str = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>";
2505
2506/// An **ontology-aware** coarse graph: instead of structural communities, group
2507/// entities by their `rdf:type` class and aggregate relations between classes.
2508/// Returns `(subject_class, predicate, object_class, count)` over the default
2509/// graph. Entities with no type are `(untyped)`; literals are `(literal)`.
2510/// `rdf:type` triples themselves define the classes and are not counted as
2511/// relations. This is the dataset's effective schema with instance volumes.
2512pub fn schema_summary(rete: &Rete) -> Vec<(String, String, String, u32)> {
2513    use std::collections::{BTreeMap, HashMap};
2514    let triples = rete.dump(None);
2515
2516    let mut class_of: HashMap<&str, &str> = HashMap::new();
2517    for (s, p, o) in &triples {
2518        if p == RDF_TYPE {
2519            class_of.insert(s.as_str(), o.as_str());
2520        }
2521    }
2522    let classify = |t: &str| -> String {
2523        if let Some(c) = class_of.get(t) {
2524            (*c).to_string()
2525        } else if t.starts_with('"') {
2526            "(literal)".to_string()
2527        } else {
2528            "(untyped)".to_string()
2529        }
2530    };
2531
2532    let mut counts: BTreeMap<(String, String, String), u32> = BTreeMap::new();
2533    for (s, p, o) in &triples {
2534        if p == RDF_TYPE {
2535            continue; // type assertions define classes, not data relations
2536        }
2537        *counts
2538            .entry((classify(s), p.clone(), classify(o)))
2539            .or_default() += 1;
2540    }
2541    counts
2542        .into_iter()
2543        .map(|((a, p, b), c)| (a, p, b, c))
2544        .collect()
2545}
2546
2547/// Class populations: the number of resources of each `rdf:type` class in the
2548/// default graph, descending by count. The instance-count companion to
2549/// [`schema_summary`].
2550pub fn schema_classes(rete: &Rete) -> Vec<(String, u32)> {
2551    use std::collections::BTreeMap;
2552    let mut counts: BTreeMap<String, u32> = BTreeMap::new();
2553    for (_s, p, o) in rete.dump(None) {
2554        if p == RDF_TYPE {
2555            *counts.entry(o).or_default() += 1;
2556        }
2557    }
2558    let mut out: Vec<(String, u32)> = counts.into_iter().collect();
2559    out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
2560    out
2561}
2562
2563/// Fetch **only** the metadata section (the opaque Dataset Card blob) via a
2564/// [`RangeReader`]: read the 1 KiB header, then the metadata byte range —
2565/// nothing else. This is the index-free CARD tier of the exploration model: a
2566/// remote/S3 client learns the dataset's self-description in **two small range
2567/// requests**, never touching the dictionary, index, or pyramid. Returns `None`
2568/// when the file carries no metadata.
2569///
2570/// Companion to [`Rete::open_ranged`] (which deliberately *skips* the card to
2571/// keep the query path minimal); this is the explicit "I want the card" path.
2572pub fn read_metadata_ranged<R: RangeReader>(reader: &R) -> Result<Option<Vec<u8>>, FileError> {
2573    let head = reader.read_at(0, HEADER_LEN as u64)?;
2574    if crate::v06::open::is_v06(&head) {
2575        let h = crate::v06::Header06::from_bytes(&head)?;
2576        return Ok(crate::v06::open::read_card_build(reader, &h)?.0);
2577    }
2578    let header = Header::from_bytes(&head)?;
2579    if header.metadata_len == 0 {
2580        return Ok(None);
2581    }
2582    let bytes = reader.read_at(header.metadata_offset, header.metadata_len)?;
2583    Ok(Some(bytes))
2584}
2585
2586/// Fetch the metadata (Dataset Card) **and** build-info sections via a
2587/// [`RangeReader`] in the fewest requests: the 1 KiB header, then — because the
2588/// writer lays the build-info section immediately after the metadata — **one**
2589/// coalesced range covering both. A file with only one of the two costs the
2590/// same two requests; a file with neither costs just the header read. This
2591/// keeps "card + build conditions" within the CARD tier's 1 header + 1 range
2592/// budget instead of adding a third request.
2593///
2594/// Returns `(metadata, build_info)`, each `None` when absent.
2595#[allow(clippy::type_complexity)]
2596pub fn read_card_and_build_info_ranged<R: RangeReader>(
2597    reader: &R,
2598) -> Result<(Option<Vec<u8>>, Option<Vec<u8>>), FileError> {
2599    read_card_and_build_info_with_header(reader).map(|(_, m, b)| (m, b))
2600}
2601
2602/// [`read_card_and_build_info_ranged`], also returning the parsed [`Header`] it
2603/// had to read anyway.
2604///
2605/// The header is not a bonus: it carries the content hash and the section
2606/// directory — including whether the file has a TEXT_INDEX section, which the
2607/// card does not state (see
2608/// [`read_text_index_token_table_len_ranged`]). A caller that wants both and
2609/// used the tuple-only form would issue a **second** 1 KiB request for bytes it
2610/// already had, turning the CARD tier's documented "one header + one coalesced
2611/// range" into three requests.
2612#[allow(clippy::type_complexity)]
2613pub fn read_card_and_build_info_with_header<R: RangeReader>(
2614    reader: &R,
2615) -> Result<(Header, Option<Vec<u8>>, Option<Vec<u8>>), FileError> {
2616    let head = reader.read_at(0, HEADER_LEN as u64)?;
2617    let header = Header::from_bytes(&head)?;
2618    if header.version == crate::v06::VERSION {
2619        let h = crate::v06::Header06::from_bytes(&head)?;
2620        let (card, build) = crate::v06::open::read_card_build(reader, &h)?;
2621        return Ok((header, card, build));
2622    }
2623    let meta = (header.metadata_offset, header.metadata_len);
2624    let build = (header.build_info_offset, header.build_info_len);
2625    if meta.1 > 0 && build.1 > 0 && build.0 == meta.0 + meta.1 {
2626        // Adjacent (the layout this crate writes): one read spans both.
2627        let both = reader.read_at(meta.0, meta.1 + build.1)?;
2628        let (m, b) = usize::try_from(meta.1)
2629            .ok()
2630            .and_then(|mid| both.split_at_checked(mid))
2631            .ok_or(FileError::Container("short card read"))?;
2632        return Ok((header, Some(m.to_vec()), Some(b.to_vec())));
2633    }
2634    let fetch = |off: u64, len: u64| -> Result<Option<Vec<u8>>, FileError> {
2635        if len == 0 {
2636            return Ok(None);
2637        }
2638        Ok(Some(reader.read_at(off, len)?))
2639    };
2640    Ok((header, fetch(meta.0, meta.1)?, fetch(build.0, build.1)?))
2641}
2642
2643/// The build-info section of a whole file image, or `None` if absent. The
2644/// payload is an opaque application-layer blob (the CLI stores build-conditions
2645/// JSON there); it is **not** covered by the content hash — see
2646/// [`attach_build_info`].
2647pub fn read_build_info(bytes: &[u8]) -> Result<Option<Vec<u8>>, FileError> {
2648    if crate::v06::open::is_v06(bytes) {
2649        let h = crate::v06::Header06::from_bytes(bytes)?;
2650        let r = crate::reader::SliceReader::new(bytes);
2651        return Ok(crate::v06::open::read_card_build(&r, &h)?.1);
2652    }
2653    let header = Header::from_bytes(bytes)?;
2654    if header.build_info_len == 0 {
2655        return Ok(None);
2656    }
2657    let section = checked_slice(bytes, header.build_info_offset, header.build_info_len)
2658        .ok_or(FileError::Container("build-info section overruns buffer"))?;
2659    Ok(Some(section.to_vec()))
2660}
2661
2662/// Everything needed to splice a build-info section into a finished file
2663/// **without holding the file in memory**: the rewritten header, where the
2664/// section goes, and where the bytes that do not move resume.
2665///
2666/// See [`plan_build_info`] for what the fields mean together: the build-info
2667/// section always follows the metadata section, so the layout is
2668/// `[header][metadata][build info][everything else, verbatim]` — or, for a
2669/// card placed after the data, `[header][data…][metadata][build info][footer]`.
2670#[derive(Debug, Clone, PartialEq, Eq)]
2671pub struct BuildInfoPlan {
2672    /// The header bytes to write at offset 0.
2673    pub header: [u8; HEADER_LEN],
2674    /// Offset the new section is written at: the end of the metadata section
2675    /// (the length of the unchanged prefix up to and including the card).
2676    pub insert: u64,
2677    /// Offset in the **old** file where the bytes that do not move resume. The
2678    /// span `insert..tail_start` is the old build-info section, if any, and is
2679    /// the only part of the old file the new one drops.
2680    pub tail_start: u64,
2681    /// Length of the file this plan produces.
2682    pub new_len: u64,
2683}
2684
2685/// Plan the splice of a build-info section of `info_len` bytes into a file
2686/// whose first [`HEADER_LEN`] bytes are `head` and whose total length is
2687/// `file_len`.
2688///
2689/// The section goes immediately after the metadata section — adjacent, so
2690/// [`read_card_and_build_info_ranged`] fetches card + build info in one
2691/// coalesced range — and every later section's offset shifts by the length
2692/// delta. That shift is the whole of the arithmetic, and it lives here so the
2693/// in-memory splice ([`attach_build_info`]) and any streaming rewriter derive
2694/// the same header from the same rule instead of each carrying a copy that can
2695/// drift.
2696///
2697/// The content hash is **deliberately left untouched**: build info records the
2698/// facts that differ between two builds of identical data (timestamp, builder,
2699/// measured costs), and folding them into the hash would break the
2700/// reproducible-hash property. `verify` accordingly ignores this section, on an
2701/// old reader (which sees an unknown kind-7 entry) and a new one alike — so a
2702/// file that gains, loses or changes a build record **keeps its content
2703/// identity**.
2704///
2705/// `info_len` of 0 plans the removal of an existing section.
2706pub fn plan_build_info(
2707    head: &[u8],
2708    file_len: u64,
2709    info_len: u64,
2710) -> Result<BuildInfoPlan, FileError> {
2711    let mut header = Header::from_bytes(head)?;
2712    // The insert point: immediately after the metadata section, wherever it
2713    // lies — at HEADER_LEN as the in-RAM writers place it, or after the data
2714    // where the memory-bounded build and `recard --in-place` put it
2715    // (`crate::recard::plan_card_append`). No card: right after the header.
2716    let insert = if header.metadata_len > 0 {
2717        header
2718            .metadata_offset
2719            .checked_add(header.metadata_len)
2720            .filter(|&end| header.metadata_offset >= HEADER_LEN as u64 && end <= file_len)
2721            .ok_or(FileError::Container(
2722                "the metadata section lies outside the file",
2723            ))?
2724    } else {
2725        HEADER_LEN as u64
2726    };
2727    let old_len = header.build_info_len;
2728    if old_len > 0 && header.build_info_offset != insert {
2729        return Err(FileError::Container(
2730            "existing build-info section is not adjacent to the metadata",
2731        ));
2732    }
2733    let tail_start = insert.saturating_add(old_len);
2734    if tail_start > file_len || insert > file_len {
2735        return Err(FileError::Container("build-info splice out of bounds"));
2736    }
2737
2738    // Shift every section that lives at or after the old tail.
2739    let shift = |off: &mut u64, len: u64| {
2740        if len > 0 && *off >= tail_start {
2741            *off = *off - old_len + info_len;
2742        }
2743    };
2744    shift(&mut header.dictionary_offset, header.dictionary_len);
2745    shift(&mut header.root_dir_offset, header.root_dir_len);
2746    shift(&mut header.pyramid_meta_offset, header.pyramid_meta_len);
2747    shift(&mut header.named_graphs_offset, header.named_graphs_len);
2748    shift(&mut header.text_index_offset, header.text_index_len);
2749    for s in &mut header.extra_sections {
2750        if s.length > 0 && s.offset >= tail_start {
2751            s.offset = s.offset - old_len + info_len;
2752        }
2753    }
2754    if info_len == 0 {
2755        header.build_info_offset = 0;
2756        header.build_info_len = 0;
2757    } else {
2758        header.build_info_offset = insert;
2759        header.build_info_len = info_len;
2760    }
2761
2762    Ok(BuildInfoPlan {
2763        header: header.to_bytes(),
2764        insert,
2765        tail_start,
2766        new_len: file_len - old_len + info_len,
2767    })
2768}
2769
2770/// Attach (or replace) a **build-info** section in a finished file image,
2771/// returning the new image. Layout and hash semantics: [`plan_build_info`].
2772///
2773/// Passing an empty `info` removes an existing section (or returns the image
2774/// unchanged when there is none).
2775pub fn attach_build_info(image: &[u8], info: &[u8]) -> Result<Vec<u8>, FileError> {
2776    if crate::v06::open::is_v06(image) {
2777        // 0x06: the record lives in the card region; the card stays.
2778        let h = crate::v06::Header06::from_bytes(image)?;
2779        let (card, _) =
2780            crate::v06::open::read_card_build(&crate::reader::SliceReader::new(image), &h)?;
2781        return crate::v06::write::replace_card(
2782            image,
2783            card.as_deref(),
2784            (!info.is_empty()).then_some(info),
2785        );
2786    }
2787    let plan = plan_build_info(image, image.len() as u64, info.len() as u64)?;
2788    let mut out = Vec::with_capacity(plan.new_len as usize);
2789    out.extend_from_slice(&plan.header);
2790    out.extend_from_slice(&image[HEADER_LEN..plan.insert as usize]);
2791    out.extend_from_slice(info);
2792    out.extend_from_slice(&image[plan.tail_start as usize..]);
2793    debug_assert_eq!(out.len() as u64, plan.new_len);
2794    Ok(out)
2795}
2796
2797/// Replace the **metadata** section (the Dataset Card blob) of a finished file
2798/// image, returning the new image. The section is the first one after the
2799/// header, so every later section's offset shifts by the length delta.
2800///
2801/// Unlike [`attach_build_info`], the metadata payload **is** inside the content
2802/// hash — it is part of what the file says about itself — so the hash is
2803/// recomputed here. The result is byte-identical to what the writer would have
2804/// produced had it been handed this payload in the first place
2805/// (`write_dataset_with_metadata`): the section sits at `HEADER_LEN`, every
2806/// later offset is derived from its length, and the hash folds the same parts
2807/// in the same order.
2808///
2809/// Why splice rather than rebuild: the only caller that needs this is a build
2810/// that has already assembled the file and then learned something about it by
2811/// **running** its own starter queries against it. Re-deriving the whole image
2812/// to correct a metadata blob would mean re-parsing the input; a splice costs
2813/// one memcpy and one rehash of an image that is already resident.
2814///
2815/// Passing an empty payload removes the section.
2816pub fn replace_metadata(image: &[u8], metadata: &[u8]) -> Result<Vec<u8>, FileError> {
2817    if crate::v06::open::is_v06(image) {
2818        // 0x06: the card region is rewritten; every section is kept.
2819        let h = crate::v06::Header06::from_bytes(image)?;
2820        let (_, build) =
2821            crate::v06::open::read_card_build(&crate::reader::SliceReader::new(image), &h)?;
2822        return crate::v06::write::replace_card(
2823            image,
2824            (!metadata.is_empty()).then_some(metadata),
2825            build.as_deref(),
2826        );
2827    }
2828    let mut header = Header::from_bytes(image)?;
2829    // The writers in this crate always place metadata (when present) at
2830    // HEADER_LEN; anything else is a file this function must not reshape.
2831    let insert = HEADER_LEN as u64;
2832    if header.metadata_len > 0 && header.metadata_offset != insert {
2833        return Err(FileError::Container(
2834            "metadata section does not sit immediately after the header",
2835        ));
2836    }
2837    let old_len = header.metadata_len;
2838    let tail_start = (insert + old_len) as usize;
2839    if tail_start > image.len() {
2840        return Err(FileError::Container("metadata splice out of bounds"));
2841    }
2842    let new_len = metadata.len() as u64;
2843
2844    let shift = |off: &mut u64, len: u64| {
2845        if len > 0 && *off >= insert + old_len {
2846            *off = *off - old_len + new_len;
2847        }
2848    };
2849    shift(&mut header.dictionary_offset, header.dictionary_len);
2850    shift(&mut header.root_dir_offset, header.root_dir_len);
2851    shift(&mut header.pyramid_meta_offset, header.pyramid_meta_len);
2852    shift(&mut header.named_graphs_offset, header.named_graphs_len);
2853    shift(&mut header.text_index_offset, header.text_index_len);
2854    shift(&mut header.build_info_offset, header.build_info_len);
2855    for s in &mut header.extra_sections {
2856        if s.length > 0 && s.offset >= insert + old_len {
2857            s.offset = s.offset - old_len + new_len;
2858        }
2859    }
2860    header.metadata_offset = insert;
2861    header.metadata_len = new_len;
2862
2863    let mut out = Vec::with_capacity(image.len() - old_len as usize + metadata.len());
2864    out.extend_from_slice(&header.to_bytes());
2865    out.extend_from_slice(metadata);
2866    out.extend_from_slice(&image[tail_start..]);
2867    // The payload changed, so the hash must: recompute it over the same part
2868    // list `verify` will rebuild from this very header, then re-serialize the
2869    // header carrying it.
2870    header.content_hash = hash_of_sections(&header, &out)?;
2871    out[..HEADER_LEN].copy_from_slice(&header.to_bytes());
2872    Ok(out)
2873}
2874
2875/// The content hash of a file image, folded over exactly the parts the writer
2876/// hashed (see `write_dataset_from_parts`): the metadata payload when present,
2877/// then dict, index, pyramid-meta, and — when present — the text index and the
2878/// named graphs. [`verify`] compares this against the header; [`replace_metadata`]
2879/// writes it.
2880fn hash_of_sections(header: &Header, bytes: &[u8]) -> Result<[u8; 16], FileError> {
2881    let slice = |off: u64, len: u64| -> Result<&[u8], FileError> {
2882        // `off` and `len` come STRAIGHT FROM THE FILE HEADER, so a crafted or
2883        // corrupt .rete can set them near u64::MAX and make `off + len` overflow
2884        // — which panics in debug and wraps in release, BEFORE `.get()` gets the
2885        // chance to reject the range. Found by the weekly fuzz run
2886        // (fuzz_targets/open.rs, "attempt to add with overflow"). Saturating the
2887        // sum turns both cases into the same clean Container error.
2888        // `checked_slice` also refuses an offset past this target's usize
2889        // (wasm32), which `as usize` used to wrap onto other bytes.
2890        checked_slice(bytes, off, len).ok_or(FileError::Container("section overruns buffer"))
2891    };
2892    let parts = content_hash_parts(header)
2893        .into_iter()
2894        .map(|(off, len)| slice(off, len))
2895        .collect::<Result<Vec<&[u8]>, FileError>>()?;
2896    Ok(content_hash(&parts))
2897}
2898
2899/// The byte ranges the content hash folds, **in the order it folds them**: the
2900/// metadata payload when present, then dictionary, index, pyramid-meta, and —
2901/// when present — a kind-6 text index and the named graphs. Build info, the
2902/// self-digested kinds (8 LABEL_INDEX, 9 SCHEMA, 11 predicate-restricted text
2903/// index) and any section kind this reader does not know are deliberately
2904/// outside it. The quad index (kind 10) is not a range here either: its digest
2905/// is the seal at the end of the index container, so it is hashed by reference.
2906///
2907/// The one list [`verify`], [`verify_ranged`] and [`replace_metadata`] hash
2908/// over, and what a streaming rewriter (`rete recard`) feeds a hasher while it
2909/// copies — so a section a future format adds to the hash is added here, once,
2910/// and every one of them follows.
2911pub fn content_hash_parts(header: &Header) -> Vec<(u64, u64)> {
2912    let mut parts: Vec<(u64, u64)> = Vec::with_capacity(6);
2913    if header.metadata_len > 0 {
2914        parts.push((header.metadata_offset, header.metadata_len));
2915    }
2916    parts.push((header.dictionary_offset, header.dictionary_len));
2917    parts.push((header.root_dir_offset, header.root_dir_len));
2918    // A zero-length pyramid folds nothing either way; it is listed only when
2919    // present so the ranges a rewriter walks are all real sections.
2920    if header.pyramid_meta_len > 0 {
2921        parts.push((header.pyramid_meta_offset, header.pyramid_meta_len));
2922    }
2923    // A kind-11 (predicate-restricted) text index is self-digested instead.
2924    if header.text_index_len > 0 && !header.text_index_partial {
2925        parts.push((header.text_index_offset, header.text_index_len));
2926    }
2927    if header.named_graphs_len > 0 {
2928        parts.push((header.named_graphs_offset, header.named_graphs_len));
2929    }
2930    parts
2931}
2932
2933/// `verify` for a `0x06` file over any reader: what failed (empty = all good).
2934/// `only` restricts the section checks to one kind (`verify --section`).
2935pub fn verify_ranged_v06(
2936    reader: &dyn crate::reader::RangeReader,
2937    head: &[u8],
2938    only: Option<u8>,
2939) -> Result<Vec<String>, FileError> {
2940    const SLICE: u64 = 8 << 20;
2941    let h = crate::v06::Header06::from_bytes(head)?;
2942    if h.file_len != reader.len() {
2943        return Ok(vec!["file length".to_string()]);
2944    }
2945    let mut bad = Vec::new();
2946    let root_bytes = reader.read_at(crate::v06::HEADER_LEN as u64, h.root_len as u64)?;
2947    if crate::v06::digest16(&root_bytes) != h.root_digest {
2948        bad.push("root".to_string());
2949        return Ok(bad);
2950    }
2951    let root = crate::v06::Root06::decode(&root_bytes, h.internal_codec)?;
2952    if crate::v06::content_hash(&h) != h.content_hash {
2953        bad.push("content hash".to_string());
2954    }
2955    let card = h.card_range();
2956    if only.is_none() && card.len > 0 {
2957        let stored = reader.read_at(card.offset, card.len)?;
2958        if crate::v06::digest16(&stored) != h.card_digest {
2959            bad.push("card".to_string());
2960        }
2961    }
2962    for s in &root.sections {
2963        if only.is_some_and(|k| k != s.kind) {
2964            continue;
2965        }
2966        let a = match s.abs(&h) {
2967            Ok(a) if a.offset + a.len <= h.file_len => a,
2968            _ => {
2969                bad.push(crate::v06::kind::name(s.kind).to_string());
2970                continue;
2971            }
2972        };
2973        let mut hs = blake3::Hasher::new();
2974        let mut at = a.offset;
2975        while at < a.offset + a.len {
2976            let n = (a.offset + a.len - at).min(SLICE);
2977            hs.update(&reader.read_at(at, n)?);
2978            at += n;
2979        }
2980        if hs.finalize().as_bytes()[..16] != s.digest {
2981            bad.push(crate::v06::kind::name(s.kind).to_string());
2982        }
2983    }
2984    if only.is_none() {
2985        let n = crate::v06::FOOTER.len() as u64;
2986        if reader.read_at(h.file_len - n, n)? != crate::v06::FOOTER {
2987            bad.push("footer".to_string());
2988        }
2989    }
2990    Ok(bad)
2991}
2992
2993/// Recompute the content hash from a file image and check it against the header
2994/// — detects corruption or truncation of the payload sections. The
2995/// LABEL_INDEX, SCHEMA and predicate-restricted TEXT_INDEX sections (kinds 8,
2996/// 9, 11) are outside that hash — an older reader's `verify` must keep passing
2997/// on a file that carries them — so each is checked against its own leading
2998/// digest instead. The QUAD_INDEX section (kind 10) is hashed by reference: its
2999/// digest is the seal inside the (hashed) index container, checked here.
3000pub fn verify(bytes: &[u8]) -> Result<bool, FileError> {
3001    if crate::v06::open::is_v06(bytes) {
3002        let r: std::sync::Arc<dyn RangeReader + Send + Sync> =
3003            std::sync::Arc::new(crate::reader::BytesReader(std::sync::Arc::from(bytes)));
3004        return crate::v06::open::V06File::open(r, bytes)?.verify(None);
3005    }
3006    let header = Header::from_bytes(bytes)?;
3007    if hash_of_sections(&header, bytes)? != header.content_hash {
3008        return Ok(false);
3009    }
3010    // The QUAD_INDEX section is covered through the seal's digest (inside the
3011    // hashed index container), so it is checked against that.
3012    if header.has_quad_index() || header.quad_index_section().is_some() {
3013        check_named_layout(&header)?;
3014        let slice = |off: u64, len: u64| -> Result<&[u8], FileError> {
3015            checked_slice(bytes, off, len).ok_or(FileError::Container("section overruns buffer"))
3016        };
3017        let root = slice(header.root_dir_offset, header.root_dir_len)?;
3018        let ranges = container_section_payload_ranges(
3019            root,
3020            header.root_dir_offset,
3021            header.index_container_sections(),
3022        )?;
3023        let seal = ranges
3024            .last()
3025            .ok_or(FileError::Container("missing quad-index seal"))?;
3026        let want = crate::quad::parse_seal(slice(seal.offset, seal.len)?)?;
3027        let q = header
3028            .quad_index_section()
3029            .ok_or(FileError::Container("missing quad-index section"))?;
3030        if crate::quad::digest(slice(q.offset, q.length)?) != want {
3031            return Ok(false);
3032        }
3033    }
3034    for s in self_digested_sections(&header) {
3035        let section = checked_slice(bytes, s.offset, s.length)
3036            .ok_or(FileError::Container("section overruns buffer"))?;
3037        if !crate::label_index::digest_ok(section) {
3038            return Ok(false);
3039        }
3040    }
3041    Ok(true)
3042}
3043
3044/// The sections that carry their own leading 16-byte digest rather than being
3045/// folded into the header content hash: LABEL_INDEX (8), SCHEMA (9) and a
3046/// predicate-restricted TEXT_INDEX (11).
3047fn self_digested_sections(header: &Header) -> Vec<Section> {
3048    [
3049        header.label_index_section(),
3050        header.schema_section(),
3051        header
3052            .section(crate::header::SectionKind::TextIndexPartial)
3053            .filter(|s| s.length > 0),
3054    ]
3055    .into_iter()
3056    .flatten()
3057    .collect()
3058}
3059
3060/// Append LABEL_INDEX / SCHEMA sections (kinds 8, 9) to a finished file image,
3061/// just before its 4-byte footer, and return the new image.
3062///
3063/// They sit outside the content hash, so nothing before them moves and the
3064/// hash is unchanged: only the header's directory gains entries. An empty
3065/// payload is skipped. Each kind may be appended once; a second append of a
3066/// kind the file already carries is an error, as is any other kind.
3067pub fn append_sections(
3068    image: &[u8],
3069    sections: &[(SectionKind, &[u8])],
3070) -> Result<Vec<u8>, FileError> {
3071    let lens: Vec<(SectionKind, u64)> =
3072        sections.iter().map(|(k, b)| (*k, b.len() as u64)).collect();
3073    let (header, body_end) = plan_append(image, image.len() as u64, &lens)?;
3074    let extra: usize = sections.iter().map(|(_, b)| b.len()).sum();
3075    let mut out = Vec::with_capacity(image.len() + extra);
3076    out.extend_from_slice(&header.to_bytes());
3077    out.extend_from_slice(&image[HEADER_LEN..body_end as usize]);
3078    for (_, b) in sections {
3079        out.extend_from_slice(b);
3080    }
3081    out.extend_from_slice(&MAGIC);
3082    Ok(out)
3083}
3084
3085/// The header an append produces, and where the old footer starts.
3086fn plan_append(
3087    head: &[u8],
3088    file_len: u64,
3089    sections: &[(SectionKind, u64)],
3090) -> Result<(Header, u64), FileError> {
3091    let mut header = Header::from_bytes(head)?;
3092    if header.expected_file_len() != Some(file_len) {
3093        return Err(FileError::Container(
3094            "cannot append sections: the file length does not match its header",
3095        ));
3096    }
3097    let body_end = file_len - MAGIC.len() as u64;
3098    let mut at = body_end;
3099    for &(kind, len) in sections {
3100        if !matches!(kind, SectionKind::LabelIndex | SectionKind::Schema) {
3101            return Err(FileError::Container(
3102                "only LABEL_INDEX and SCHEMA sections can be appended",
3103            ));
3104        }
3105        if len == 0 {
3106            continue;
3107        }
3108        if header.section(kind).is_some_and(|s| s.length > 0) {
3109            return Err(FileError::Container(
3110                "the file already carries that section",
3111            ));
3112        }
3113        header = header.with_section(kind, at, len);
3114        at += len;
3115    }
3116    Ok((header, body_end))
3117}
3118
3119/// Open a file's dictionary over a [`RangeReader`] with only its chunk
3120/// directories resident — for the memory-bounded build, which resolves the
3121/// SCHEMA section's few class and predicate ids through the file it just
3122/// wrote.
3123#[cfg(not(target_arch = "wasm32"))]
3124pub(crate) fn open_dictionary_lazy<R: RangeReader + Send + Sync + 'static>(
3125    reader: R,
3126) -> Result<Dictionary, FileError> {
3127    let head = reader.read_at(0, HEADER_LEN as u64)?;
3128    let header = Header::from_bytes(&head)?;
3129    ranged_chunked_dictionary(&std::sync::Arc::new(reader), &header, [true; 4])
3130}
3131
3132/// [`verify`] over a [`RangeReader`](crate::reader::RangeReader), streaming:
3133/// the hashed sections are read in bounded slices and folded into one BLAKE3
3134/// state, so peak memory is one slice, not the file. The same sections, in
3135/// the same order, as `hash_of_sections`; on a 49 GB file the in-memory form
3136/// needs 49 GB.
3137pub fn verify_ranged(reader: &dyn crate::reader::RangeReader) -> Result<bool, FileError> {
3138    /// Read size per step: large enough that a local or remote read is
3139    /// throughput-bound, small enough to be negligible next to any budget.
3140    const SLICE: u64 = 8 << 20;
3141    let len = reader.len();
3142    if len < HEADER_LEN as u64 {
3143        return Err(FileError::Container("file shorter than its header"));
3144    }
3145    let head = reader.read_at(0, HEADER_LEN as u64)?;
3146    if crate::v06::open::is_v06(&head) {
3147        return verify_ranged_v06(reader, &head, None).map(|bad| bad.is_empty());
3148    }
3149    let header = Header::from_bytes(&head)?;
3150    let mut h = blake3::Hasher::new();
3151    for (off, n) in content_hash_parts(&header) {
3152        // Offsets come straight from the header: reject an overrun (or an
3153        // overflowing sum) before reading, as `hash_of_sections` does.
3154        if off.checked_add(n).is_none_or(|end| end > len) {
3155            return Err(FileError::Container("section overruns buffer"));
3156        }
3157        let mut at = off;
3158        while at < off + n {
3159            let take = (off + n - at).min(SLICE);
3160            h.update(&reader.read_at(at, take)?);
3161            at += take;
3162        }
3163    }
3164    let mut out = [0u8; 16];
3165    out.copy_from_slice(&h.finalize().as_bytes()[..16]);
3166    if out != header.content_hash {
3167        return Ok(false);
3168    }
3169    // The QUAD_INDEX section, against the digest in the index container's seal
3170    // (see `verify`), streamed the same way.
3171    if header.has_quad_index() || header.quad_index_section().is_some() {
3172        check_named_layout(&header)?;
3173        let sections = header.index_container_sections();
3174        let seal = locate_container_section_ranged(
3175            reader,
3176            header.root_dir_offset,
3177            header.root_dir_len,
3178            sections - 1,
3179            sections as u64,
3180        )?;
3181        let want = crate::quad::parse_seal(&reader.read_at(seal.offset, seal.len)?)?;
3182        let q = header
3183            .quad_index_section()
3184            .ok_or(FileError::Container("missing quad-index section"))?;
3185        if q.offset.checked_add(q.length).is_none_or(|end| end > len) {
3186            return Err(FileError::Container("section overruns buffer"));
3187        }
3188        let mut h = blake3::Hasher::new();
3189        let mut at = q.offset;
3190        while at < q.offset + q.length {
3191            let take = (q.offset + q.length - at).min(SLICE);
3192            h.update(&reader.read_at(at, take)?);
3193            at += take;
3194        }
3195        let mut got = [0u8; 16];
3196        got.copy_from_slice(&h.finalize().as_bytes()[..16]);
3197        if got != want {
3198            return Ok(false);
3199        }
3200    }
3201    // The self-digested sections (kinds 8, 9, 11), streamed the same way.
3202    for s in self_digested_sections(&header) {
3203        if s.length < 16 || s.offset.checked_add(s.length).is_none_or(|end| end > len) {
3204            return Err(FileError::Container("section overruns buffer"));
3205        }
3206        let want = reader.read_at(s.offset, 16)?;
3207        let mut h = blake3::Hasher::new();
3208        let mut at = s.offset + 16;
3209        while at < s.offset + s.length {
3210            let take = (s.offset + s.length - at).min(SLICE);
3211            h.update(&reader.read_at(at, take)?);
3212            at += take;
3213        }
3214        if h.finalize().as_bytes()[..16] != want[..] {
3215            return Ok(false);
3216        }
3217    }
3218    Ok(true)
3219}
3220
3221/// Faults the pyramid meta in on first access. `None` = the fetch failed.
3222type PyramidLoader = Box<dyn Fn() -> Option<PyramidMeta> + Send + Sync>;
3223
3224/// The pyramid-meta section, held either resident (eager opens) or deferred
3225/// (the lazy remote open). SPARQL never touches the pyramid, but on a Wikidata
3226/// file it can be tens of MB (114k communities, millions of superedges), so a
3227/// remote SPARQL query must not pay to fetch it — it faults in only when a
3228/// community/pyramid query actually calls [`Rete::pyramid`].
3229enum PyramidSlot {
3230    Resident(Option<PyramidMeta>),
3231    Lazy {
3232        loader: PyramidLoader,
3233        cell: std::sync::OnceLock<Option<PyramidMeta>>,
3234    },
3235}
3236
3237/// A section held resident (eager opens) or faulted in on first use (lazy
3238/// opens) — the LABEL_INDEX and SCHEMA sections. `None` inside means the file
3239/// has no such section, or it could not be read or parsed: both are optional
3240/// and derived, so a bad one degrades to "absent" (and `verify` reports it).
3241pub(crate) enum SectionSlot<T> {
3242    Resident(Option<T>),
3243    Lazy {
3244        loader: Box<dyn Fn() -> Option<T> + Send + Sync>,
3245        cell: std::sync::OnceLock<Option<T>>,
3246    },
3247}
3248
3249impl<T> SectionSlot<T> {
3250    /// The section, faulting it in on the lazy path.
3251    pub(crate) fn get(&self) -> Option<&T> {
3252        match self {
3253            SectionSlot::Resident(v) => v.as_ref(),
3254            SectionSlot::Lazy { loader, cell } => cell.get_or_init(loader).as_ref(),
3255        }
3256    }
3257
3258    /// The section only if it is resident or already faulted — never a read.
3259    pub(crate) fn get_if_loaded(&self) -> Option<&T> {
3260        match self {
3261            SectionSlot::Resident(v) => v.as_ref(),
3262            SectionSlot::Lazy { cell, .. } => cell.get().and_then(|o| o.as_ref()),
3263        }
3264    }
3265}
3266
3267/// The LABEL_INDEX of a file image held in memory.
3268fn resident_label_slot<'a>(
3269    header: &Header,
3270    region: &dyn Fn(u64, u64) -> Result<&'a [u8], FileError>,
3271) -> SectionSlot<crate::label_index::LabelIndex> {
3272    SectionSlot::Resident(header.label_index_section().and_then(|s| {
3273        let bytes = region(s.offset, s.length).ok()?;
3274        crate::label_index::LabelIndex::from_section(bytes, header.block_codec).ok()
3275    }))
3276}
3277
3278/// The SCHEMA section of a file image held in memory.
3279fn resident_schema_slot<'a>(
3280    header: &Header,
3281    region: &dyn Fn(u64, u64) -> Result<&'a [u8], FileError>,
3282) -> SectionSlot<crate::schema_section::SchemaMeta> {
3283    SectionSlot::Resident(header.schema_section().and_then(|s| {
3284        crate::schema_section::SchemaMeta::decode(region(s.offset, s.length).ok()?).ok()
3285    }))
3286}
3287
3288/// The deferred LABEL_INDEX of a ranged open: the first search reads the
3289/// section head (preamble + root, ≤ [`crate::label_index::HEAD_READ`] bytes),
3290/// then one directory node per level and the data blocks it needs.
3291fn ranged_label_slot<R: RangeReader + Send + Sync + 'static>(
3292    reader: &std::sync::Arc<R>,
3293    header: &Header,
3294) -> SectionSlot<crate::label_index::LabelIndex> {
3295    let Some(s) = header.label_index_section() else {
3296        return SectionSlot::Resident(None);
3297    };
3298    let r = reader.clone();
3299    let codec = header.block_codec;
3300    SectionSlot::Lazy {
3301        loader: Box::new(move || {
3302            let head = r
3303                .read_at(s.offset, crate::label_index::HEAD_READ.min(s.length))
3304                .ok()?;
3305            let rr = r.clone();
3306            let loader = Box::new(move |off: u64, len: u64| rr.read_at(s.offset + off, len).ok());
3307            crate::label_index::LabelIndex::from_head(&head, s.length, codec, loader).ok()
3308        }),
3309        cell: std::sync::OnceLock::new(),
3310    }
3311}
3312
3313/// The deferred SCHEMA section of a ranged open: one range read, on first use.
3314fn ranged_schema_slot<R: RangeReader + Send + Sync + 'static>(
3315    reader: &std::sync::Arc<R>,
3316    header: &Header,
3317) -> SectionSlot<crate::schema_section::SchemaMeta> {
3318    let Some(s) = header.schema_section() else {
3319        return SectionSlot::Resident(None);
3320    };
3321    let r = reader.clone();
3322    SectionSlot::Lazy {
3323        loader: Box::new(move || {
3324            let bytes = r.read_at(s.offset, s.length).ok()?;
3325            crate::schema_section::SchemaMeta::decode(&bytes).ok()
3326        }),
3327        cell: std::sync::OnceLock::new(),
3328    }
3329}
3330
3331/// Read and decode a file's SCHEMA section over a [`RangeReader`] — the
3332/// header and one range. `Ok(None)` when the file has none (or it is
3333/// malformed: the section is optional and derived).
3334pub fn read_schema_ranged<R: RangeReader + ?Sized>(
3335    reader: &R,
3336) -> Result<Option<crate::schema_section::SchemaMeta>, FileError> {
3337    let head = reader.read_at(0, HEADER_LEN as u64)?;
3338    let header = Header::from_bytes(&head)?;
3339    Ok(read_schema_with_header(reader, &header))
3340}
3341
3342fn read_schema_with_header<R: RangeReader + ?Sized>(
3343    reader: &R,
3344    header: &Header,
3345) -> Option<crate::schema_section::SchemaMeta> {
3346    if header.version == crate::v06::VERSION {
3347        let b = crate::v06::open::read_schema_bytes(reader).ok()??;
3348        return crate::schema_section::SchemaMeta::decode(&b).ok();
3349    }
3350    let s = header.schema_section()?;
3351    let bytes = reader.read_at(s.offset, s.length).ok()?;
3352    crate::schema_section::SchemaMeta::decode(&bytes).ok()
3353}
3354
3355/// Faults the text index in on first search. `None` = the file has none, or the
3356/// fetch/parse failed.
3357type TextIndexLoader = Box<dyn Fn() -> Option<crate::text_index::TextIndex> + Send + Sync>;
3358
3359/// Answers the token table's byte length from the section's first ≤10 bytes —
3360/// the length varint alone, never the table it measures.
3361type TokenTableProbe = Box<dyn Fn() -> Option<u64> + Send + Sync>;
3362
3363/// The TEXT_INDEX section, held either resident (eager opens decode the whole
3364/// thing) or deferred (the lazy remote open keeps only a loader that fetches the
3365/// token table on first search, then faults posting lists one at a time). SPARQL
3366/// never touches it, so the lazy remote path keeps its small range budget.
3367///
3368/// Both variants can also answer how long the section's leading **token table**
3369/// is without faulting it — the figure a caller needs to state what a first
3370/// search will cost, since `header.text_index_len` counts the postings blob too
3371/// and overstates it by several times on a literal-heavy graph.
3372enum TextIndexSlot {
3373    Resident {
3374        index: Option<crate::text_index::TextIndex>,
3375        /// Measured from the section bytes this open already held; `None` when
3376        /// the file carries no text index.
3377        token_table_len: Option<u64>,
3378    },
3379    Lazy {
3380        loader: TextIndexLoader,
3381        cell: std::sync::OnceLock<Option<crate::text_index::TextIndex>>,
3382        /// One ≤10-byte range read, memoized in `token_table_cell`.
3383        token_table: TokenTableProbe,
3384        token_table_cell: std::sync::OnceLock<Option<u64>>,
3385    },
3386}
3387
3388impl TextIndexSlot {
3389    /// The parsed index, faulting it in (token table first) on the lazy path.
3390    fn index(&self) -> Option<&crate::text_index::TextIndex> {
3391        match self {
3392            TextIndexSlot::Resident { index, .. } => index.as_ref(),
3393            TextIndexSlot::Lazy { loader, cell, .. } => cell.get_or_init(loader).as_ref(),
3394        }
3395    }
3396
3397    /// Byte length of the section's leading token table — see
3398    /// [`Rete::text_index_token_table_len`].
3399    fn token_table_len(&self) -> Option<u64> {
3400        match self {
3401            TextIndexSlot::Resident {
3402                token_table_len, ..
3403            } => *token_table_len,
3404            TextIndexSlot::Lazy {
3405                token_table,
3406                token_table_cell,
3407                ..
3408            } => *token_table_cell.get_or_init(token_table),
3409        }
3410    }
3411}
3412
3413/// Decode a whole TEXT_INDEX section held in memory, measuring its token table
3414/// on the way past. `section` is `None` when the file carries no text index.
3415fn resident_text_index_slot(section: Option<&[u8]>, codec: u8) -> Result<TextIndexSlot, FileError> {
3416    let Some(section) = section else {
3417        return Ok(TextIndexSlot::Resident {
3418            index: None,
3419            token_table_len: None,
3420        });
3421    };
3422    Ok(TextIndexSlot::Resident {
3423        index: Some(
3424            crate::text_index::TextIndex::from_section(section, codec)
3425                .map_err(|_| FileError::Container("malformed text index"))?,
3426        ),
3427        token_table_len: crate::text_index::TextIndex::postings_base(section).map(|b| b as u64),
3428    })
3429}
3430
3431/// Byte length of the TEXT_INDEX section's leading **token table**, from a
3432/// header a caller has already parsed — the prefix range a first
3433/// [`Rete::text_search`] fetches, and the honest figure to quote as its cost
3434/// (`header.text_index_len` counts the postings blob too and overstates it
3435/// several-fold). `None` when the file carries no text index, or its first
3436/// bytes could not be read.
3437///
3438/// Costs **one range read of ≤10 bytes** — the section's leading length varint
3439/// and nothing else, never the table it measures. This is the free-standing
3440/// companion to [`Rete::text_index_token_table_len`], for readers that hold a
3441/// [`Header`] and a [`RangeReader`] but never open the file (the CARD tier:
3442/// `rete card`, `rete card-url`).
3443pub fn read_text_index_token_table_len_ranged<R: RangeReader + ?Sized>(
3444    reader: &R,
3445    header: &Header,
3446) -> Option<u64> {
3447    if header.version == crate::v06::VERSION {
3448        return crate::v06::open::read_token_table_bytes(reader);
3449    }
3450    let (ti_off, ti_len) = header.text_index_payload();
3451    if ti_len == 0 {
3452        return None;
3453    }
3454    read_token_table_len(reader, ti_off, ti_len)
3455}
3456
3457/// Byte length of the TEXT_INDEX section's leading token table — its length
3458/// varint plus the compressed table, i.e. the prefix range a first search
3459/// fetches. Reads the section's first ≤10 bytes and nothing else.
3460fn read_token_table_len<R: RangeReader + ?Sized>(reader: &R, off: u64, len: u64) -> Option<u64> {
3461    let head = reader.read_at(off, 10u64.min(len)).ok()?;
3462    let (ttlen, n) = crate::varint::read_uvarint(&head)?;
3463    Some((n as u64 + ttlen).min(len))
3464}
3465
3466/// A named graph's container decodes RESIDENT (one range read, one decode) up
3467/// to this size on the lazy path; a larger container opens as a remote-lazy
3468/// tile index instead, so one selective `GRAPH <g>` query over a multi-GB
3469/// graph faults only the tile directories plus the tiles it touches.
3470const NAMED_GRAPH_RESIDENT_MAX: u64 = 1 << 20; // 1 MiB
3471
3472/// Largest quad-index graph (in quads) a many-graph walk evaluates through an
3473/// owned, dropped view ([`Rete::named_graph_transient`]) rather than a memoised
3474/// one. A view is ~1–2 KB of descriptors whatever the graph's size, so keeping
3475/// one per graph is what grows with the graph count; past this size a graph is
3476/// worth memoising, and its rows are no longer worth materializing per graph.
3477pub const TRANSIENT_GRAPH_QUADS: u64 = 1024;
3478
3479/// Entries per lazily-allocated named-graph slab. The outer table is sized by
3480/// the section's leading count varint, which is UNTRUSTED — slabbing keeps a
3481/// hostile count from ballooning the allocation (the outer table costs a few
3482/// bytes per claimed slab; entry storage only materializes when touched).
3483const NAMED_SLAB: usize = 1024;
3484
3485/// First read of the named-graph walk, and the floor its ramp resets to. One
3486/// chunk covers a few dozen small-graph records, so a `LIMIT`ed `GRAPH ?g`
3487/// pays exactly one small read — the targeted-query win this size protects.
3488const NAMED_WALK_CHUNK: u64 = 64 * 1024;
3489
3490/// Ceiling of the walk's geometric read ramp — the read size an exhaustive
3491/// walk converges to (and starts at, when the demand is known exhaustive).
3492///
3493/// Sized from measurement, not taste. Against the nkod reference file
3494/// (67.9 MB, 31,974 named graphs, 65.4 MB section) the fixed 64 KiB walk cost
3495/// 262 requests / ~16.5 s in Chromium where the old eager whole-section fetch
3496/// cost ~8.5 s — i.e. ~31 ms of per-request overhead on ~8 s of transfer
3497/// (~7.7 MB/s). At 8 MiB per read a chunk transfers in ~1 s, putting the
3498/// request overhead near 3% of total time; halving the cap to 4 MiB doubles
3499/// that overhead, doubling the cap to 16 MiB buys back under 2% while doubling
3500/// both the walk buffer a wasm32 session must hold resident and the worst-case
3501/// over-read past the point a query stops. 8 MiB is the knee.
3502const NAMED_WALK_CHUNK_MAX: u64 = 8 * 1024 * 1024;
3503
3504/// One named graph, walked lazily out of the NAMED_GRAPHS section.
3505#[derive(Default)]
3506struct NamedEntry {
3507    /// `(iri, absolute byte range of the graph's index container)` — filled
3508    /// when the sequential directory walk reaches this slot.
3509    meta: std::sync::OnceLock<(String, ByteRange)>,
3510    /// The graph's permutation index, opened on first access. Boxed: most
3511    /// entries of a many-graph dataset are never touched by a given query.
3512    index: std::sync::OnceLock<Box<GraphIndex>>,
3513}
3514
3515/// Sequential position of the lazy named-graphs walk. The section has no
3516/// random-access directory — each entry's offset is the previous entry's end —
3517/// so entries are parsed strictly in order and memoised.
3518#[derive(Default)]
3519struct NamedWalk {
3520    /// Next entry index to parse.
3521    next: usize,
3522    /// Absolute file offset of that entry's header; `0` = the leading count
3523    /// varint has not been consumed yet (offset 0 is the file header, never
3524    /// inside a section).
3525    pos: u64,
3526    /// Read buffer carried ACROSS walk calls: `buf` holds the section bytes at
3527    /// `[buf_off, buf_off + buf.len())`. Entry-by-entry iteration (the
3528    /// `GRAPH ?g` path advances one entry per call) parses ~30 small-graph
3529    /// headers per 64 KiB chunk instead of re-reading a chunk per entry.
3530    /// [`LazyNamedGraphs::open_graph`] also serves whole small containers out
3531    /// of it, so a bulk chunk's ride-along payloads are never re-fetched.
3532    buf: Vec<u8>,
3533    buf_off: u64,
3534    /// Current read size of the geometric ramp (`0` = not started; treated as
3535    /// [`NAMED_WALK_CHUNK`]). Doubles on every buffer refill up to
3536    /// [`NAMED_WALK_CHUNK_MAX`], so a walk that keeps going converges to bulk
3537    /// reads after fetching at most as many bytes as it already used (the
3538    /// doubling bound: wasted read-ahead ≤ useful bytes). Reset to the floor
3539    /// whenever an oversized container is parsed — see `big_seen`.
3540    chunk: u64,
3541    /// A container larger than [`NAMED_GRAPH_RESIDENT_MAX`] has been parsed.
3542    /// Such graphs open tile-lazily (their payload bytes are NOT wanted by the
3543    /// walk), so ride-along prefetch stops paying off: the ramp resets and the
3544    /// exhaustive-demand hint stops seeding full-size reads. Without this, a
3545    /// header walk over a file of multi-MB containers would fetch the payloads
3546    /// it exists to hop over.
3547    big_seen: bool,
3548}
3549
3550/// The NAMED_GRAPHS section opened lazily over a [`RangeReader`]: nothing is
3551/// fetched at open. The section is a count varint followed by
3552/// `iri_len | iri | container_len | container` records, so the directory is
3553/// walked forward on demand — header bytes only, skipping container payloads —
3554/// and each touched graph's index is decoded (or opened tile-lazily) on first
3555/// access. A `LIMIT`ed `GRAPH ?g` query therefore reads a prefix of the
3556/// section; only a query that genuinely touches every graph (e.g. a full
3557/// `COUNT` over `GRAPH ?g`) walks it all — which is exactly what the eager
3558/// open used to fetch unconditionally.
3559///
3560/// **Failure contract** (same as lazy tiles/chunks): a failed read or a
3561/// malformed record sets a sticky flag and the accessor returns `None`;
3562/// nothing failed is ever memoised, so a later evaluation retries.
3563struct LazyNamedGraphs {
3564    reader: std::sync::Arc<dyn RangeReader + Send + Sync>,
3565    /// Absolute byte range of the NAMED_GRAPHS section.
3566    section: ByteRange,
3567    codec: u8,
3568    has_synopsis: bool,
3569    read_concurrency: usize,
3570    /// The file's permutation set — every graph container in a file carries
3571    /// the same one, so it comes from the header rather than per graph.
3572    perms: PermSet,
3573    /// `(count, slab table)` — set once the leading count varint is read.
3574    /// Slabs allocate on first touch; boxed slices never move, so `&` handed
3575    /// out to entries stay valid for `&self`'s lifetime with no unsafe.
3576    #[allow(clippy::type_complexity)]
3577    dir: std::sync::OnceLock<(usize, Box<[std::sync::OnceLock<Box<[NamedEntry]>>]>)>,
3578    walk: std::sync::Mutex<NamedWalk>,
3579    failed: std::sync::atomic::AtomicBool,
3580    /// Tile-cache byte cap applied to each LARGE (lazily-opened, loader-bearing)
3581    /// named graph as it is opened (bounded-export phase 2). `u64::MAX` =
3582    /// unlimited. Small resident graphs (no loader, ≤ 1 MiB) are never capped —
3583    /// an evicted tile could not be reloaded — but they are bounded by size and
3584    /// released per slot, so they need no cap.
3585    tile_cap: std::sync::atomic::AtomicU64,
3586}
3587
3588impl LazyNamedGraphs {
3589    fn new(
3590        reader: std::sync::Arc<dyn RangeReader + Send + Sync>,
3591        section: ByteRange,
3592        codec: u8,
3593        has_synopsis: bool,
3594        read_concurrency: usize,
3595        perms: PermSet,
3596    ) -> Self {
3597        LazyNamedGraphs {
3598            reader,
3599            section,
3600            codec,
3601            has_synopsis,
3602            read_concurrency,
3603            perms,
3604            dir: std::sync::OnceLock::new(),
3605            walk: std::sync::Mutex::new(NamedWalk::default()),
3606            failed: std::sync::atomic::AtomicBool::new(false),
3607            tile_cap: std::sync::atomic::AtomicU64::new(u64::MAX),
3608        }
3609    }
3610
3611    /// Set the tile-cache cap for large named graphs: records it for graphs
3612    /// opened later, and re-caps every large graph already opened. Small
3613    /// resident (loaderless) graphs are left unlimited.
3614    fn set_tile_cap(&self, cap: u64) {
3615        self.tile_cap
3616            .store(cap, std::sync::atomic::Ordering::Relaxed);
3617        self.for_each_opened(|g| {
3618            if g.is_lazy() {
3619                g.set_cache_cap(cap);
3620            }
3621        });
3622    }
3623
3624    /// Record a fetch/parse failure (checked by `index_incomplete`).
3625    fn fail(&self) {
3626        self.failed
3627            .store(true, std::sync::atomic::Ordering::Relaxed);
3628    }
3629
3630    /// The directory scaffold, reading the leading count varint on first call.
3631    #[allow(clippy::type_complexity)]
3632    fn directory(&self) -> Option<&(usize, Box<[std::sync::OnceLock<Box<[NamedEntry]>>]>)> {
3633        if let Some(d) = self.dir.get() {
3634            return Some(d);
3635        }
3636        let end = self.section.offset.checked_add(self.section.len)?;
3637        let (n, used) = match read_uvarint_at(&self.reader, self.section.offset, end) {
3638            Ok(v) => v,
3639            Err(_) => {
3640                self.fail();
3641                return None;
3642            }
3643        };
3644        // Each record needs at least 2 bytes of framing (two length varints),
3645        // so a count beyond len/2 is malformed — refuse before sizing by it.
3646        if n > self.section.len / 2 {
3647            self.fail();
3648            return None;
3649        }
3650        let Ok(n) = usize::try_from(n) else {
3651            self.fail();
3652            return None;
3653        };
3654        let slabs = n.div_ceil(NAMED_SLAB);
3655        let table: Box<[std::sync::OnceLock<Box<[NamedEntry]>>]> =
3656            (0..slabs).map(|_| std::sync::OnceLock::new()).collect();
3657        let _ = self.dir.set((n, table));
3658        {
3659            // Idempotent under an init race: both racers computed the same
3660            // `used` from the same bytes.
3661            let mut w = self.walk.lock().unwrap();
3662            if w.pos == 0 {
3663                w.pos = self.section.offset + used;
3664            }
3665        }
3666        self.dir.get()
3667    }
3668
3669    fn count(&self) -> usize {
3670        self.directory().map(|(n, _)| *n).unwrap_or(0)
3671    }
3672
3673    /// The `i`-th entry's cell (allocating its slab on first touch).
3674    fn entry(&self, i: usize) -> Option<&NamedEntry> {
3675        let (n, table) = self.directory()?;
3676        if i >= *n {
3677            return None;
3678        }
3679        let slab = table[i / NAMED_SLAB].get_or_init(|| {
3680            let len = NAMED_SLAB.min(n - (i / NAMED_SLAB) * NAMED_SLAB);
3681            (0..len).map(|_| NamedEntry::default()).collect()
3682        });
3683        slab.get(i % NAMED_SLAB)
3684    }
3685
3686    /// The next walk read's length, advancing the geometric ramp: at least
3687    /// `need` (a long IRI's header), at most the section remainder, and
3688    /// otherwise the current ramp value — which doubles per refill up to
3689    /// [`NAMED_WALK_CHUNK_MAX`]. The doubling keeps a stopping walk's wasted
3690    /// read-ahead bounded by the bytes it already used, while a walk that
3691    /// keeps going converges to bulk reads: the runtime safety net for
3692    /// exhaustive-looking queries the demand analysis could not prove.
3693    fn next_read_len(w: &mut NamedWalk, pos: u64, end: u64, need: u64) -> u64 {
3694        let cur = w.chunk.max(NAMED_WALK_CHUNK);
3695        w.chunk = (cur * 2).min(NAMED_WALK_CHUNK_MAX);
3696        cur.max(need).min(end - pos)
3697    }
3698
3699    /// Walk entry headers forward until `entries[upto].meta` is set. Reads the
3700    /// section in chunks and hops over container payloads, so the walk fetches
3701    /// O(headers) — not O(section) — where containers are large; small
3702    /// containers ride along in the same chunk and cost nothing extra.
3703    ///
3704    /// `exhaustive` is the demand hint from the query engine: the caller has
3705    /// PROVEN it will visit and open every named graph (an unrestricted
3706    /// `GRAPH ?g` under a consumer that drains the pipeline), so the walk
3707    /// starts at full-size reads instead of ramping up to them — the whole
3708    /// section arrives in `section/8 MiB` requests instead of hundreds. The
3709    /// hint is per-call, never memoised: a later targeted query on the same
3710    /// handle walks small again. It also defers to the layout: once an
3711    /// oversized (tile-lazy) container is seen, ride-along bytes stop being
3712    /// useful and the hint no longer seeds bulk reads.
3713    fn ensure_meta(&self, upto: usize, exhaustive: bool) -> Option<()> {
3714        let target = self.entry(upto)?;
3715        if target.meta.get().is_some() {
3716            return Some(());
3717        }
3718        let end = self.section.offset.checked_add(self.section.len)?;
3719        let mut w = self.walk.lock().unwrap();
3720        if exhaustive && !w.big_seen {
3721            w.chunk = NAMED_WALK_CHUNK_MAX;
3722        }
3723        while w.next <= upto {
3724            let pos = w.pos;
3725            if pos >= end {
3726                // The count varint promised more entries than the bytes hold.
3727                self.fail();
3728                return None;
3729            }
3730            // Make sure the whole header (two varints + the IRI, ≤ 20+iri_len
3731            // bytes) is buffered; refetch a larger window when the IRI is long.
3732            let have =
3733                |b: &[u8], off: u64, need: u64| pos >= off && pos + need <= off + b.len() as u64;
3734            if !have(&w.buf, w.buf_off, 20.min(end - pos)) {
3735                let len = Self::next_read_len(&mut w, pos, end, 20.min(end - pos));
3736                w.buf = match self.reader.read_at(pos, len) {
3737                    Ok(b) => b,
3738                    Err(_) => {
3739                        self.fail();
3740                        return None;
3741                    }
3742                };
3743                w.buf_off = pos;
3744            }
3745            // Every slice of `buf` below is checked: a reader may return fewer
3746            // bytes than asked, and the lengths are untrusted.
3747            let rel = (pos - w.buf_off) as usize;
3748            let Some((ilen, u1)) = w.buf.get(rel..).and_then(read_uvarint) else {
3749                self.fail();
3750                return None;
3751            };
3752            let Some(iri_end) = (u1 as u64)
3753                .checked_add(ilen)
3754                .and_then(|n| pos.checked_add(n))
3755                .filter(|&e| e <= end)
3756            else {
3757                self.fail(); // IRI overruns the section: malformed
3758                return None;
3759            };
3760            // iri_len + iri + container_len varint
3761            let header_need = (iri_end - pos).saturating_add(10);
3762            if !have(&w.buf, w.buf_off, header_need.min(end - pos)) {
3763                let len = Self::next_read_len(&mut w, pos, end, header_need);
3764                w.buf = match self.reader.read_at(pos, len) {
3765                    Ok(b) => b,
3766                    Err(_) => {
3767                        self.fail();
3768                        return None;
3769                    }
3770                };
3771                w.buf_off = pos;
3772            }
3773            let rel = (pos - w.buf_off) as usize;
3774            let istart = rel + u1;
3775            let Some(iend) = usize::try_from(ilen)
3776                .ok()
3777                .and_then(|l| istart.checked_add(l))
3778                .filter(|&e| e <= w.buf.len())
3779            else {
3780                self.fail(); // short read: the header is not all here
3781                return None;
3782            };
3783            let iri = String::from_utf8_lossy(&w.buf[istart..iend]).into_owned();
3784            let Some((clen, u2)) = read_uvarint(&w.buf[iend..]) else {
3785                self.fail();
3786                return None;
3787            };
3788            let cend = match iri_end
3789                .checked_add(u2 as u64)
3790                .and_then(|cstart| cstart.checked_add(clen))
3791            {
3792                Some(e) if e <= end => e,
3793                _ => {
3794                    self.fail(); // container overruns the section: malformed
3795                    return None;
3796                }
3797            };
3798            if clen > NAMED_GRAPH_RESIDENT_MAX {
3799                // This graph opens tile-lazily: the walk exists to HOP its
3800                // payload, so big read-ahead here is pure over-fetch. Drop the
3801                // ramp back to the floor and stop the exhaustive hint from
3802                // re-seeding it (headers stay cheap on payload-heavy files).
3803                w.big_seen = true;
3804                w.chunk = NAMED_WALK_CHUNK;
3805            }
3806            let range = ByteRange {
3807                offset: cend - clen,
3808                len: clen,
3809            };
3810            if let Some(e) = self.entry(w.next) {
3811                let _ = e.meta.set((iri, range));
3812            }
3813            w.pos = cend;
3814            w.next += 1;
3815        }
3816        Some(())
3817    }
3818
3819    /// The `i`-th graph's IRI — a header walk, never an index decode.
3820    /// `exhaustive`: see [`ensure_meta`](Self::ensure_meta).
3821    fn name_at(&self, i: usize, exhaustive: bool) -> Option<&str> {
3822        self.ensure_meta(i, exhaustive)?;
3823        self.entry(i)?.meta.get().map(|(iri, _)| iri.as_str())
3824    }
3825
3826    /// The `i`-th graph as `(iri, index)`, opening the index on first access.
3827    /// `exhaustive`: see [`ensure_meta`](Self::ensure_meta).
3828    fn graph_at(&self, i: usize, exhaustive: bool) -> Option<(&str, &GraphIndex)> {
3829        self.ensure_meta(i, exhaustive)?;
3830        let e = self.entry(i)?;
3831        let (iri, range) = e.meta.get()?;
3832        if e.index.get().is_none() {
3833            let opened = self.open_graph(*range)?;
3834            let _ = e.index.set(Box::new(opened));
3835        }
3836        Some((iri.as_str(), e.index.get()?.as_ref()))
3837    }
3838
3839    /// A small container's bytes: out of the walk buffer when the last chunk
3840    /// already carried them (bulk chunks always do — that is their point),
3841    /// else one targeted range read. Never called for tile-lazy containers.
3842    fn container_bytes(&self, range: ByteRange) -> Option<Vec<u8>> {
3843        {
3844            let w = self.walk.lock().unwrap();
3845            let buf_end = w.buf_off + w.buf.len() as u64;
3846            if range.offset >= w.buf_off && range.offset + range.len <= buf_end {
3847                let a = (range.offset - w.buf_off) as usize;
3848                return Some(w.buf[a..a + range.len as usize].to_vec());
3849            }
3850        }
3851        match self.reader.read_at(range.offset, range.len) {
3852            // A short read is a failed read, not a shorter container.
3853            Ok(b) if b.len() as u64 == range.len => Some(b),
3854            _ => {
3855                self.fail();
3856                None
3857            }
3858        }
3859    }
3860
3861    /// Open one graph's index container: small ones resident (one read),
3862    /// large ones as a remote-lazy tile index — the same machinery as the
3863    /// default graph, so a selective query over a huge named graph fetches
3864    /// only the tile directories plus the tiles it touches.
3865    fn open_graph(&self, range: ByteRange) -> Option<GraphIndex> {
3866        if range.len <= NAMED_GRAPH_RESIDENT_MAX {
3867            let bytes = self.container_bytes(range)?;
3868            match decode_index_container(&bytes, self.codec, self.perms) {
3869                Ok(g) => {
3870                    // Resident, so counting is a pass over tile headers: the
3871                    // planner sizes this graph's patterns by it.
3872                    g.set_size_hint(u64::from(g.triple_count()));
3873                    Some(g)
3874                }
3875                Err(_) => {
3876                    self.fail();
3877                    None
3878                }
3879            }
3880        } else {
3881            match open_index_container_lazy(
3882                &self.reader,
3883                range,
3884                self.codec,
3885                self.has_synopsis,
3886                self.read_concurrency,
3887                self.perms,
3888                self.perms.len(),
3889            ) {
3890                Ok((g, _, _)) => {
3891                    // A large graph faults its tiles through a loader, so bound
3892                    // its tile residency to the budget's share (an evicted tile
3893                    // simply re-faults). No-op when unlimited.
3894                    let cap = self.tile_cap.load(std::sync::atomic::Ordering::Relaxed);
3895                    if cap != u64::MAX {
3896                        g.set_cache_cap(cap);
3897                    }
3898                    Some(g)
3899                }
3900                Err(_) => {
3901                    self.fail();
3902                    None
3903                }
3904            }
3905        }
3906    }
3907
3908    /// Drop the decoded index of the graph named `iri`, if it has been opened,
3909    /// so its tiles and per-graph tile cache are freed. Takes `&mut self`:
3910    /// `OnceLock::take` needs unique access, which the export loop has between
3911    /// slots. Leaves the entry's `meta` (iri + range) so the graph re-opens
3912    /// lazily if touched again. A no-op if the graph is absent or never opened.
3913    fn release(&mut self, iri: &str) {
3914        let Some((_, table)) = self.dir.get_mut() else {
3915            return;
3916        };
3917        for slab in table.iter_mut() {
3918            let Some(entries) = slab.get_mut() else {
3919                continue;
3920            };
3921            for e in entries.iter_mut() {
3922                let matches = e
3923                    .meta
3924                    .get()
3925                    .map(|(name, _)| name.as_str() == iri)
3926                    .unwrap_or(false);
3927                if matches {
3928                    e.index.take();
3929                    return;
3930                }
3931            }
3932        }
3933    }
3934
3935    /// Visit every already-opened graph index (never triggers a fetch).
3936    fn for_each_opened(&self, mut f: impl FnMut(&GraphIndex)) {
3937        if let Some((_, table)) = self.dir.get() {
3938            for slab in table.iter().filter_map(|s| s.get()) {
3939                for e in slab.iter() {
3940                    if let Some(g) = e.index.get() {
3941                        f(g);
3942                    }
3943                }
3944            }
3945        }
3946    }
3947}
3948
3949/// The NAMED_GRAPHS section, held resident (in-memory and eager ranged opens —
3950/// unchanged) or walked lazily (the lazy ranged open, where fetching and
3951/// decoding every graph's index up front defeated remote laziness: 67 MB
3952/// fetched and ~32k indexes built before the first query on a many-graph file).
3953enum NamedGraphsSlot {
3954    Resident(Vec<(String, GraphIndex)>),
3955    Lazy(LazyNamedGraphs),
3956    /// The quad index (kind 10), on every open path: graphs are views over
3957    /// shared tiles, found through the dictionary and the sorted directory.
3958    Quad(crate::quad::QuadNamed),
3959}
3960
3961impl NamedGraphsSlot {
3962    /// Heap bytes the named-graph directory holds outside any cache: every
3963    /// walked entry's IRI and range, the walk buffer, and the directories of
3964    /// the graph indexes currently open (see [`GraphIndex::directory_bytes`]).
3965    fn directory_bytes(&self) -> u64 {
3966        match self {
3967            NamedGraphsSlot::Resident(v) => v
3968                .iter()
3969                .map(|(iri, g)| iri.capacity() as u64 + g.directory_bytes())
3970                .sum(),
3971            NamedGraphsSlot::Lazy(l) => {
3972                let mut bytes = l.walk.lock().map(|w| w.buf.capacity() as u64).unwrap_or(0);
3973                if let Some((_, table)) = l.dir.get() {
3974                    bytes += table.len() as u64
3975                        * std::mem::size_of::<std::sync::OnceLock<Box<[NamedEntry]>>>() as u64;
3976                    for slab in table.iter().filter_map(|s| s.get()) {
3977                        bytes += slab.len() as u64 * std::mem::size_of::<NamedEntry>() as u64;
3978                        for e in slab.iter() {
3979                            if let Some((iri, _)) = e.meta.get() {
3980                                bytes += iri.capacity() as u64;
3981                            }
3982                            if let Some(g) = e.index.get() {
3983                                bytes += g.directory_bytes();
3984                            }
3985                        }
3986                    }
3987                }
3988                bytes
3989            }
3990            NamedGraphsSlot::Quad(q) => q.directory_bytes(),
3991        }
3992    }
3993
3994    fn count(&self) -> usize {
3995        match self {
3996            NamedGraphsSlot::Resident(v) => v.len(),
3997            NamedGraphsSlot::Lazy(l) => l.count(),
3998            NamedGraphsSlot::Quad(q) => q.count(),
3999        }
4000    }
4001
4002    /// `exhaustive` is the walk-demand hint (resident opens ignore it): see
4003    /// [`LazyNamedGraphs::ensure_meta`]. `dict` names the graphs of a quad
4004    /// index (their names are dictionary terms there).
4005    fn name_at<'a>(&'a self, dict: &Dictionary, i: usize, exhaustive: bool) -> Option<&'a str> {
4006        match self {
4007            NamedGraphsSlot::Resident(v) => v.get(i).map(|(iri, _)| iri.as_str()),
4008            NamedGraphsSlot::Lazy(l) => l.name_at(i, exhaustive),
4009            NamedGraphsSlot::Quad(q) => q.name_at(dict, i, exhaustive),
4010        }
4011    }
4012
4013    fn graph_at<'a>(
4014        &'a self,
4015        dict: &Dictionary,
4016        i: usize,
4017        exhaustive: bool,
4018    ) -> Option<(&'a str, &'a GraphIndex)> {
4019        match self {
4020            NamedGraphsSlot::Resident(v) => v.get(i).map(|(iri, g)| (iri.as_str(), g)),
4021            NamedGraphsSlot::Lazy(l) => l.graph_at(i, exhaustive),
4022            NamedGraphsSlot::Quad(q) => q.graph_at(dict, i, exhaustive),
4023        }
4024    }
4025
4026    fn find<'a>(&'a self, dict: &Dictionary, iri: &str) -> Option<&'a GraphIndex> {
4027        match self {
4028            NamedGraphsSlot::Quad(q) => q.find(dict, iri),
4029            NamedGraphsSlot::Resident(v) => v.iter().find(|(name, _)| name == iri).map(|(_, g)| g),
4030            NamedGraphsSlot::Lazy(l) => {
4031                // Walk headers only until the IRI matches; decode just that
4032                // graph's index. An absent IRI costs a full header walk — the
4033                // layout has no by-name directory. A point lookup is targeted
4034                // demand: never hint the walk exhaustive.
4035                for i in 0..l.count() {
4036                    if l.name_at(i, false)? == iri {
4037                        return l.graph_at(i, false).map(|(_, g)| g);
4038                    }
4039                }
4040                None
4041            }
4042        }
4043    }
4044
4045    fn load_incomplete(&self) -> bool {
4046        match self {
4047            NamedGraphsSlot::Resident(v) => v.iter().any(|(_, g)| g.load_incomplete()),
4048            NamedGraphsSlot::Lazy(l) => {
4049                if l.failed.load(std::sync::atomic::Ordering::Relaxed) {
4050                    return true;
4051                }
4052                let mut bad = false;
4053                l.for_each_opened(|g| bad |= g.load_incomplete());
4054                bad
4055            }
4056            NamedGraphsSlot::Quad(q) => q.load_incomplete(),
4057        }
4058    }
4059
4060    fn reset_load_failures(&self) {
4061        match self {
4062            NamedGraphsSlot::Resident(v) => {
4063                for (_, g) in v {
4064                    g.reset_load_failure();
4065                }
4066            }
4067            NamedGraphsSlot::Lazy(l) => {
4068                l.failed.store(false, std::sync::atomic::Ordering::Relaxed);
4069                l.for_each_opened(|g| g.reset_load_failure());
4070            }
4071            NamedGraphsSlot::Quad(q) => q.reset_load_failures(),
4072        }
4073    }
4074
4075    /// The named-graph slot of a file opened with all its bytes in hand
4076    /// (`Rete::open`, `open_ranged`): the per-graph section decoded resident,
4077    /// or the quad index read through an owned copy of its section.
4078    fn resident(
4079        header: &Header,
4080        quad_section: impl FnOnce(u64, u64) -> Result<Vec<u8>, FileError>,
4081        per_graph: impl FnOnce() -> Result<Vec<(String, GraphIndex)>, FileError>,
4082    ) -> Result<Self, FileError> {
4083        check_named_layout(header)?;
4084        if let Some(q) = header.quad_index_section() {
4085            let bytes = quad_section(q.offset, q.length)?;
4086            return Ok(NamedGraphsSlot::Quad(crate::quad::QuadNamed::resident(
4087                bytes,
4088                header.block_codec,
4089                header.perms,
4090            )));
4091        }
4092        Ok(NamedGraphsSlot::Resident(if header.named_graphs_len > 0 {
4093            per_graph()?
4094        } else {
4095            Vec::new()
4096        }))
4097    }
4098}
4099
4100/// A read-only, in-memory view over a `.rete` file image.
4101pub struct Rete {
4102    header: Header,
4103    dict: Dictionary,
4104    index: GraphIndex,
4105    index_section_ranges: [ByteRange; NUM_PERMS],
4106    /// Per-permutation tile directories as absolute file ranges
4107    /// (`(min_a, max_a, compressed-tile range)`), for provenance. Empty for
4108    /// pre-tiling (v0.1) files.
4109    tile_ranges: [Vec<(u32, u32, ByteRange)>; NUM_PERMS],
4110    pyramid: PyramidSlot,
4111    text_index: TextIndexSlot,
4112    /// The LABEL_INDEX section (kind 8), if any.
4113    labels: SectionSlot<crate::label_index::LabelIndex>,
4114    /// The SCHEMA section (kind 9), if any.
4115    schema: SectionSlot<crate::schema_section::SchemaMeta>,
4116    named_graphs: NamedGraphsSlot,
4117    /// Raw bytes of the metadata section (empty if the file has none). The
4118    /// application layer decodes this (the CLI stores a JSON Dataset Card here).
4119    /// Only [`Rete::open`] populates it; [`Rete::open_ranged`] leaves it empty to
4120    /// preserve its minimal-fetch budget.
4121    metadata: Vec<u8>,
4122    /// Executes remote `SERVICE` blocks (SPARQL 1.1 federated query) — attached
4123    /// by the host via [`Rete::set_service_client`]; `None` means a non-SILENT
4124    /// `SERVICE` fails the query. Like the range readers, the engine never does
4125    /// I/O itself.
4126    service_client: Option<Box<dyn crate::service::ServiceClient>>,
4127    /// First failed non-SILENT `SERVICE` call of the current query. The row
4128    /// pipeline is infallible (the same contract as lazy tile fetches), so the
4129    /// failure is recorded here and taken by the top-level eval entry points.
4130    service_error: std::sync::Mutex<Option<String>>,
4131    /// Bytes a query's `COUNT(DISTINCT …)` set may hold before it spills to
4132    /// disk — see [`Rete::spill_threshold`].
4133    spill_threshold: std::sync::atomic::AtomicU64,
4134    /// The open `0x06` file (header, root, reader), for a `0x06` open.
4135    v06: Option<std::sync::Arc<crate::v06::open::V06File>>,
4136}
4137
4138impl Rete {
4139    /// Parse a full file image (v0 loads everything; a range-reading client
4140    /// will fetch only the sections it needs — same container format).
4141    pub fn open(bytes: &[u8]) -> Result<Self, FileError> {
4142        #[cfg(feature = "v06-parity")]
4143        if let Some(v6) = parity_image(bytes) {
4144            return Self::open(&v6);
4145        }
4146        if crate::v06::open::is_v06(bytes) {
4147            // A 0x06 image opens through the lazy machinery over the bytes in
4148            // memory: the same structures, faulted from RAM.
4149            let r: std::sync::Arc<dyn RangeReader + Send + Sync> =
4150                std::sync::Arc::new(crate::reader::BytesReader(std::sync::Arc::from(bytes)));
4151            let mut rete = Self::open_v06(r, bytes, false)?;
4152            rete.metadata = rete
4153                .v06
4154                .as_ref()
4155                .and_then(|f| f.card().ok().flatten())
4156                .unwrap_or_default();
4157            return Ok(rete);
4158        }
4159        let header = Header::from_bytes(bytes)?;
4160
4161        // Header offsets/lengths are untrusted (a `.rete` may be fetched truncated
4162        // or corrupt from an arbitrary URL). Slice through a checked helper so a
4163        // bad region yields an error instead of panicking on an OOB index.
4164        let region = |off: u64, len: u64| -> Result<&[u8], FileError> {
4165            checked_slice(bytes, off, len)
4166                .ok_or(FileError::Container("section range out of bounds"))
4167        };
4168
4169        let dict = decode_dictionary_container(
4170            region(header.dictionary_offset, header.dictionary_len)?,
4171            header.dict_codec,
4172        )?;
4173
4174        check_named_layout(&header)?;
4175        let index_bytes = region(header.root_dir_offset, header.root_dir_len)?;
4176        let (index, _) = decode_root_index_container(index_bytes, &header)?;
4177        let index_section_ranges = decode_index_section_ranges(
4178            index_bytes,
4179            header.root_dir_offset,
4180            header.perms,
4181            header.index_container_sections(),
4182        )?;
4183
4184        let pyramid = PyramidSlot::Resident(if header.pyramid_meta_len > 0 {
4185            Some(
4186                PyramidMeta::decode(region(header.pyramid_meta_offset, header.pyramid_meta_len)?)
4187                    .map_err(|_| FileError::Container("malformed pyramid meta"))?,
4188            )
4189        } else {
4190            None
4191        });
4192
4193        // The TEXT_INDEX section (opt-in `--text-index`); decode the whole thing
4194        // resident on a full-image open.
4195        let (ti_off, ti_len) = header.text_index_payload();
4196        let text_index = resident_text_index_slot(
4197            if ti_len > 0 {
4198                Some(region(ti_off, ti_len)?)
4199            } else {
4200                None
4201            },
4202            header.block_codec,
4203        )?;
4204        let labels = resident_label_slot(&header, &region);
4205        let schema = resident_schema_slot(&header, &region);
4206
4207        let named_graphs = NamedGraphsSlot::resident(
4208            &header,
4209            |off, len| Ok(region(off, len)?.to_vec()),
4210            || {
4211                decode_named_graphs(
4212                    region(header.named_graphs_offset, header.named_graphs_len)?,
4213                    header.block_codec,
4214                    header.perms,
4215                )
4216            },
4217        )?;
4218
4219        let metadata = if header.metadata_len > 0 {
4220            region(header.metadata_offset, header.metadata_len)?.to_vec()
4221        } else {
4222            Vec::new()
4223        };
4224
4225        let tile_ranges =
4226            tile_file_ranges(index_bytes, header.root_dir_offset, &index_section_ranges);
4227        Ok(Self {
4228            header,
4229            dict,
4230            index,
4231            index_section_ranges,
4232            tile_ranges,
4233            pyramid,
4234            text_index,
4235            labels,
4236            schema,
4237            named_graphs,
4238            metadata,
4239            service_client: None,
4240            service_error: std::sync::Mutex::new(None),
4241            spill_threshold: std::sync::atomic::AtomicU64::new(u64::MAX),
4242            v06: None,
4243        })
4244    }
4245
4246    /// Attach the client that executes `SERVICE <endpoint> { … }` blocks
4247    /// (SPARQL 1.1 federated query) against remote SPARQL endpoints. Without
4248    /// one, a non-SILENT `SERVICE` fails the query with a clear error and a
4249    /// `SERVICE SILENT` degrades to one empty solution, per the spec.
4250    pub fn set_service_client(&mut self, client: Box<dyn crate::service::ServiceClient>) {
4251        self.service_client = Some(client);
4252    }
4253
4254    pub(crate) fn service_client(&self) -> Option<&dyn crate::service::ServiceClient> {
4255        self.service_client.as_deref()
4256    }
4257
4258    /// Record a failed non-SILENT `SERVICE` call (first error wins).
4259    pub(crate) fn record_service_error(&self, msg: &str) {
4260        let mut e = self.service_error.lock().unwrap();
4261        if e.is_none() {
4262            *e = Some(msg.to_string());
4263        }
4264    }
4265
4266    /// Take (and clear) the pending `SERVICE` failure — called by every
4267    /// top-level eval entry so it can never leak into a later query.
4268    pub(crate) fn take_service_error(&self) -> Option<String> {
4269        self.service_error.lock().unwrap().take()
4270    }
4271
4272    pub fn header(&self) -> &Header {
4273        &self.header
4274    }
4275
4276    /// The file's byte layout, for visualization: header, metadata,
4277    /// dictionary, each index permutation's tile directory and individual
4278    /// tiles, pyramid summary, and named graphs — sorted by offset. Bytes not
4279    /// covered by any segment are container framing (section directories and
4280    /// length fields).
4281    pub fn file_layout(&self) -> Vec<LayoutSegment> {
4282        if let Some(f) = &self.v06 {
4283            return v06_layout(f);
4284        }
4285        let h = &self.header;
4286        let seg = |kind: &'static str, label: String, offset: u64, len: u64| LayoutSegment {
4287            kind,
4288            label,
4289            offset,
4290            len,
4291        };
4292        // The size in the label is DERIVED from the constant that defines the
4293        // span, never typed out: this line read "fixed 128 bytes" — the
4294        // pre-v0.3 header size — while emitting a 1024-byte span, and it is
4295        // what the file explorer shows a reader inspecting a real file.
4296        // `layout_header_label_matches_its_span` pins the two together.
4297        let mut out = vec![seg(
4298            "header",
4299            format!("header (fixed {} bytes)", crate::header::HEADER_LEN),
4300            0,
4301            crate::header::HEADER_LEN as u64,
4302        )];
4303        if h.metadata_len > 0 {
4304            out.push(seg(
4305                "metadata",
4306                "metadata (dataset card)".into(),
4307                h.metadata_offset,
4308                h.metadata_len,
4309            ));
4310        }
4311        out.push(seg(
4312            "dictionary",
4313            "dictionary (4 front-coded term sections)".into(),
4314            h.dictionary_offset,
4315            h.dictionary_len,
4316        ));
4317        for (si, perm) in crate::index::ALL_PERMS.into_iter().enumerate() {
4318            let sec = self.index_section_ranges[si];
4319            if sec.len == 0 {
4320                continue;
4321            }
4322            let first_tile = self.tile_ranges[si]
4323                .first()
4324                .map(|&(_, _, r)| r.offset)
4325                .unwrap_or(sec.offset + sec.len);
4326            if first_tile > sec.offset {
4327                out.push(seg(
4328                    "directory",
4329                    format!("{} tile directory", perm.name()),
4330                    sec.offset,
4331                    first_tile - sec.offset,
4332                ));
4333            }
4334            for (ti, &(min_a, max_a, r)) in self.tile_ranges[si].iter().enumerate() {
4335                out.push(seg(
4336                    "tile",
4337                    format!("{} tile {ti} (leading ids {min_a}..{max_a})", perm.name()),
4338                    r.offset,
4339                    r.len,
4340                ));
4341            }
4342        }
4343        if h.pyramid_meta_len > 0 {
4344            out.push(seg(
4345                "pyramid",
4346                "pyramid summary (communities + superedges)".into(),
4347                h.pyramid_meta_offset,
4348                h.pyramid_meta_len,
4349            ));
4350        }
4351        if h.named_graphs_len > 0 {
4352            out.push(seg(
4353                "named-graphs",
4354                format!("named graphs ({})", self.named_graphs.count()),
4355                h.named_graphs_offset,
4356                h.named_graphs_len,
4357            ));
4358        }
4359        if let Some(s) = h.label_index_section() {
4360            let label = match self.labels.get_if_loaded() {
4361                Some(li) => format!("label index ({} entries)", li.entry_count()),
4362                None => "label index".to_string(),
4363            };
4364            out.push(seg("label-index", label, s.offset, s.length));
4365        }
4366        if let Some(s) = h.schema_section() {
4367            out.push(seg(
4368                "schema",
4369                "schema (classes, query_stats, shapes)".into(),
4370                s.offset,
4371                s.length,
4372            ));
4373        }
4374        if let Some(q) = h.quad_index_section() {
4375            out.push(seg(
4376                "quad-index",
4377                format!("quad index ({} named graphs)", self.named_graphs.count()),
4378                q.offset,
4379                q.length,
4380            ));
4381        }
4382        out.sort_by_key(|s| s.offset);
4383        out
4384    }
4385
4386    /// Raw bytes of the file's metadata section, or `None` if it has none. The
4387    /// CLI stores a JSON Dataset Card here; `rete-core` treats it as opaque.
4388    /// Populated by [`Rete::open`] only — an [`Rete::open_ranged`] view returns
4389    /// `None` here (the card is not fetched on the minimal query path).
4390    pub fn metadata(&self) -> Option<&[u8]> {
4391        if self.metadata.is_empty() {
4392            None
4393        } else {
4394            Some(&self.metadata)
4395        }
4396    }
4397
4398    pub fn dictionary(&self) -> &Dictionary {
4399        &self.dict
4400    }
4401
4402    /// The pyramid metadata (summary graph + tiles), if the file has a pyramid.
4403    pub fn pyramid(&self) -> Option<&PyramidMeta> {
4404        match &self.pyramid {
4405            PyramidSlot::Resident(p) => p.as_ref(),
4406            // Faults the (possibly large) pyramid section on first access only.
4407            PyramidSlot::Lazy { loader, cell } => cell.get_or_init(loader).as_ref(),
4408        }
4409    }
4410
4411    /// The pyramid metadata **only if already resident or previously faulted** —
4412    /// never triggers a lazy range read. The query planner uses this for
4413    /// cardinality estimation so it is free for an in-memory file and never adds
4414    /// a fetch on the lazy remote path (which defers the pyramid by design).
4415    pub fn pyramid_if_loaded(&self) -> Option<&PyramidMeta> {
4416        match &self.pyramid {
4417            PyramidSlot::Resident(p) => p.as_ref(),
4418            PyramidSlot::Lazy { cell, .. } => cell.get().and_then(|o| o.as_ref()),
4419        }
4420    }
4421
4422    /// The SCHEMA section (kind 9): class hierarchy, level rollups and links,
4423    /// coherence axioms, `query_stats`, characteristic sets. Faults it in (one
4424    /// small range read) on the lazy path. `None` when the file has none — a
4425    /// file built before it existed keeps those fields in its pyramid-meta
4426    /// ([`schema_or_pyramid`](Self::schema_or_pyramid) reads either).
4427    pub fn schema(&self) -> Option<&crate::schema_section::SchemaMeta> {
4428        self.schema.get()
4429    }
4430
4431    /// The schema fields from the SCHEMA section when the file has one, else
4432    /// from the pyramid-meta (faulting either on the lazy path). `None` when
4433    /// the file carries neither.
4434    pub fn schema_or_pyramid(
4435        &self,
4436    ) -> Option<std::borrow::Cow<'_, crate::schema_section::SchemaMeta>> {
4437        if let Some(s) = self.schema() {
4438            return Some(std::borrow::Cow::Borrowed(s));
4439        }
4440        self.pyramid()
4441            .map(|p| std::borrow::Cow::Owned(crate::schema_section::SchemaMeta::from_pyramid(p)))
4442    }
4443
4444    /// Per-predicate planner statistics (`query_stats`): from the SCHEMA
4445    /// section (faulting it on the lazy path — it is small), else from the
4446    /// pyramid-meta when that is resident. Empty when the file has neither.
4447    /// See [`crate::meta::PredStat`].
4448    pub fn predicate_stats(&self) -> &[crate::meta::PredStat] {
4449        if let Some(s) = self.schema().filter(|s| !s.predicate_stats.is_empty()) {
4450            return &s.predicate_stats;
4451        }
4452        self.pyramid_if_loaded()
4453            .map(|p| p.predicate_stats.as_slice())
4454            .unwrap_or(&[])
4455    }
4456
4457    /// The statistics the planner may use **without a read it was not going to
4458    /// make**: the SCHEMA section if it is resident or already faulted — or,
4459    /// with `fault`, after faulting it (one small range, once per session) —
4460    /// else the resident pyramid-meta's block.
4461    pub(crate) fn planner_stats(&self, fault: bool) -> &[crate::meta::PredStat] {
4462        let schema = if fault {
4463            self.schema.get()
4464        } else {
4465            self.schema.get_if_loaded()
4466        };
4467        if let Some(s) = schema.filter(|s| !s.predicate_stats.is_empty()) {
4468            return &s.predicate_stats;
4469        }
4470        self.pyramid_if_loaded()
4471            .map(|p| p.predicate_stats.as_slice())
4472            .unwrap_or(&[])
4473    }
4474
4475    /// The entity shapes (characteristic sets): from the SCHEMA section, else
4476    /// from a resident pyramid-meta. See [`crate::meta::CharSet`].
4477    pub fn char_sets(&self) -> &[crate::meta::CharSet] {
4478        if let Some(s) = self.schema().filter(|s| !s.char_sets.is_empty()) {
4479            return &s.char_sets;
4480        }
4481        self.pyramid_if_loaded()
4482            .map(|p| p.char_sets.as_slice())
4483            .unwrap_or(&[])
4484    }
4485
4486    /// The **legacy** label block of a pyramid-meta (≤ 8,192 entries) — empty
4487    /// when the file has none or the pyramid isn't resident. Files built with
4488    /// `--label-index` carry a LABEL_INDEX section instead; see
4489    /// [`label_index_section`](Self::label_index_section). See
4490    /// [`crate::meta::LabelEntry`].
4491    pub fn label_index(&self) -> &[crate::meta::LabelEntry] {
4492        self.pyramid_if_loaded()
4493            .map(|p| p.label_index.as_slice())
4494            .unwrap_or(&[])
4495    }
4496
4497    /// Whether the file carries a LABEL_INDEX section (kind 8). Header only.
4498    pub fn has_label_index(&self) -> bool {
4499        self.header.label_index_section().is_some()
4500    }
4501
4502    /// The LABEL_INDEX section, faulting its head in on the lazy path.
4503    pub fn label_index_section(&self) -> Option<&crate::label_index::LabelIndex> {
4504        self.labels.get()
4505    }
4506
4507    /// Prefix-search the labels: the subjects whose label starts with `prefix`
4508    /// (case-insensitive), as `(label, subject term)`, capped at `limit`.
4509    /// Reads the LABEL_INDEX section when the file has one (every labelled
4510    /// subject, no dictionary read); otherwise the legacy pyramid-meta block,
4511    /// which **faults the pyramid** on the lazy path. Empty when the file
4512    /// carries neither.
4513    pub fn prefix_search(&self, prefix: &str, limit: usize) -> Vec<(String, String)> {
4514        if let Some(li) = self.labels.get() {
4515            return li.prefix_search(prefix, limit);
4516        }
4517        let Some(pyr) = self.pyramid() else {
4518            return Vec::new();
4519        };
4520        resolve_label_hits(&self.dict, pyr.prefix_search(prefix, limit))
4521    }
4522
4523    /// The full-text index (TEXT_INDEX section), faulting it in on first access
4524    /// on the lazy remote path. `None` when the file carries no text index.
4525    pub(crate) fn text_index(&self) -> Option<&crate::text_index::TextIndex> {
4526        self.text_index.index()
4527    }
4528
4529    /// Whether this file carries a full-text (TEXT_INDEX) section, i.e. it was
4530    /// built with `--text-index`. Cheap — reads the header, never faults.
4531    pub fn has_text_index(&self) -> bool {
4532        self.header.text_index_len > 0
4533    }
4534
4535    /// What the full-text index covers (graphs, predicates) — `None` when the
4536    /// file has none. Faults the token table on the lazy remote path, as a
4537    /// search does; the scope block lives in it.
4538    pub fn text_index_scope(&self) -> Option<crate::text_index::TextIndexScope> {
4539        self.text_index().map(|ti| ti.scope().clone())
4540    }
4541
4542    /// Byte length of the TEXT_INDEX section's leading **token table** — the
4543    /// length varint plus the compressed table, which is exactly what a first
4544    /// [`text_search`](Self::text_search) faults. `None` when the file carries
4545    /// no text index (or its head could not be read).
4546    ///
4547    /// This is the number to quote as the cost of a first search, NOT
4548    /// `header().text_index_len`: that counts the postings blob too, which is
4549    /// the bulk of the section and is only ever fetched one posting list at a
4550    /// time. On the published `epfl-infoscience.rete` the section is 195 MB and
4551    /// its token table 29 MB — a 6.5× difference.
4552    ///
4553    /// Costs nothing on a resident open (measured while the bytes were in hand);
4554    /// a lazy/ranged open pays one ≤10-byte range read, memoized, and still
4555    /// never faults the table itself.
4556    pub fn text_index_token_table_len(&self) -> Option<u64> {
4557        self.text_index.token_table_len()
4558    }
4559
4560    /// Full-text search over the literals: subject IRIs that carry **every** word
4561    /// in `words` (whole-word, case-insensitive — AND semantics), optionally also
4562    /// requiring a word that **starts with** `prefix` (token-prefix). Results are
4563    /// ordered by subject id and capped at `limit` (0 = uncapped). Empty when the
4564    /// file has no text index or nothing matches.
4565    ///
4566    /// Like [`prefix_search`](Self::prefix_search), this **faults** the index on
4567    /// the lazy remote path — a search is an explicit read, and only the queried
4568    /// posting lists are fetched, not the whole index.
4569    pub fn text_search(&self, words: &[&str], prefix: Option<&str>, limit: usize) -> Vec<String> {
4570        text_search_in(self.text_index(), &self.dict, words, prefix, limit)
4571    }
4572
4573    /// The default-graph permutation index.
4574    pub fn default_index(&self) -> &GraphIndex {
4575        &self.index
4576    }
4577
4578    /// Resolve every triple of a graph (`None` = default graph) back to terms.
4579    pub fn dump(&self, graph: Option<&str>) -> Vec<TermTriple> {
4580        let index = match graph {
4581            None => &self.index,
4582            Some(g) => match self.graph_index(g) {
4583                Some(i) => i,
4584                None => return Vec::new(),
4585            },
4586        };
4587        index
4588            .match_pattern((None, None, None))
4589            .into_iter()
4590            .filter_map(|(s, p, o)| {
4591                Some((
4592                    self.dict.subject_term(s)?,
4593                    self.dict.predicate_term(p)?,
4594                    self.dict.object_term(o)?,
4595                ))
4596            })
4597            .collect()
4598    }
4599
4600    /// Stream every triple of a graph (`None` = default) to `f`, resolving terms
4601    /// a batch at a time — no full `Vec` materialization, so it is safe on graphs far
4602    /// larger than RAM. `rete export` uses this to serialize 100M+ triple files
4603    /// that `dump()` (which collects every term into a `Vec<String>`) would OOM on.
4604    ///
4605    /// On a lazy/ranged open the dictionary is **not** prefetched whole: each
4606    /// batch faults only the chunks its own terms live in. Dumping ONE named
4607    /// graph therefore costs that graph's terms, not the file's. Dumping
4608    /// *everything* still reads every dictionary byte and leaves every chunk
4609    /// resident — that is inherent, and it, not the prefetch, is what sets the
4610    /// peak of a full export. See the comment in the body for both measurements.
4611    ///
4612    /// To dump a *slice* — one predicate, one subject, one object — call
4613    /// [`dump_filtered_each`](Self::dump_filtered_each), which is this method
4614    /// with a triple pattern and prunes tiles instead of filtering rows.
4615    pub fn dump_each<F: FnMut(&str, &str, &str)>(&self, graph: Option<&str>, f: F) {
4616        self.dump_filtered_each(graph, None, None, None, f)
4617    }
4618
4619    /// [`dump_each`](Self::dump_each) restricted to a triple **pattern** — the
4620    /// filtered dump. Any of `s` / `p` / `o` may be bound (canonical N-Triples
4621    /// term tokens); all three unbound is exactly `dump_each`.
4622    ///
4623    /// # Why this is cheap, and how much
4624    ///
4625    /// It is the same access path a routed *query* takes, not a full scan with a
4626    /// predicate test bolted on: [`GraphIndex::scan_iter`] picks the permutation
4627    /// with the longest bound prefix, binary-searches that section's tile
4628    /// directory down to the span a bound leading id can live in, and then drops
4629    /// every remaining tile whose recorded synopsis proves it cannot hold a
4630    /// bound secondary component — **all from directories, without fetching a
4631    /// tile**. So a filtered dump pays for the slice, which is the whole point
4632    /// of issue #117.
4633    ///
4634    /// Measured on `cordis.rete` (801 MB, a 417 MB dictionary, 26.4M quads,
4635    /// six named graphs), lazily opened; "before" is the only way to get a
4636    /// predicate slice out of a dump previously — dump the graph and throw away
4637    /// what does not match, in the consumer:
4638    ///
4639    /// ```text
4640    ///   graph=results  p=s66#doi     -> 337,811 rows
4641    ///     before  375.8 MB · 1797 req · 2105.8 MB RSS · 12.8 s · first row 409 ms
4642    ///     after    16.0 MB ·  105 req ·  182.6 MB RSS ·  0.41 s · first row  14 ms
4643    ///   graph=results  p=s66#isbn    ->  40,761 rows
4644    ///     before  375.8 MB · 1797 req · 2105.7 MB RSS · 12.8 s
4645    ///     after    15.5 MB ·  104 req ·  155.2 MB RSS ·  0.23 s
4646    /// ```
4647    ///
4648    /// # Where it does NOT help
4649    ///
4650    /// The index is pruned; the **dictionary is not**. Term resolution still
4651    /// faults whichever chunks the surviving rows' terms live in, and on a file
4652    /// whose payload *is* the literals those chunks are most of the file. Same
4653    /// file, a predicate whose objects are the long abstracts:
4654    ///
4655    /// ```text
4656    ///   graph=projects p=s66#abstract ->  80,206 rows
4657    ///     before  260.7 MB · 1142 req · 1496.9 MB RSS · 5.97 s
4658    ///     after   213.4 MB · 1606 req · 1144.4 MB RSS · 2.91 s   (1.2x, not 23x)
4659    /// ```
4660    ///
4661    /// An *unfiltered* dump is unchanged by construction — it is the floor, and
4662    /// its peak is the resident dictionary, not this. Two ceilings sit above
4663    /// both: a faulted dictionary chunk is a `OnceCell` nothing ever evicts, and
4664    /// on a literal-heavy file the chunk directory alone can be a third of the
4665    /// file before any dump work at all (#198).
4666    ///
4667    /// # Permutations
4668    ///
4669    /// Every one of the eight bound/unbound shapes routes inside
4670    /// [`PermSet::CORE`] — `{SPO, POS, OSP}`, the minimum a legal file carries
4671    /// (see `perm_routing_never_leaves_core`). A file built with
4672    /// `--permutations 3` therefore prunes *identically* to a six-permutation
4673    /// one: same tiles, same rows, no fallback path to get wrong.
4674    ///
4675    /// A bound term the dictionary does not know, or an absent graph IRI, yields
4676    /// nothing without touching the index.
4677    ///
4678    /// # Order
4679    ///
4680    /// Rows arrive in the **routed permutation's** order, which is the price of
4681    /// routing at all. Unfiltered (and subject-bound) that is SPO — canonical
4682    /// `(s, p, o)`, unchanged from [`dump_each`](Self::dump_each). A bound
4683    /// predicate routes to POS and streams `(p, o, s)`; a bound object routes to
4684    /// OSP. The *set* is identical to the unfiltered dump filtered; the order is
4685    /// not, so a consumer that needs canonical order must sort. N-Quads, the
4686    /// format `rete export` writes, is order-independent.
4687    ///
4688    /// [`PermSet::CORE`]: crate::index::PermSet::CORE
4689    pub fn dump_filtered_each<F: FnMut(&str, &str, &str)>(
4690        &self,
4691        graph: Option<&str>,
4692        s: Option<&str>,
4693        p: Option<&str>,
4694        o: Option<&str>,
4695        mut f: F,
4696    ) {
4697        let index = match graph {
4698            None => Some(&self.index),
4699            Some(g) => self.graph_index(g),
4700        };
4701        let Some(index) = index else { return };
4702        // A bound term the dictionary does not know can match nothing, and the
4703        // index is never touched — same contract as `query_iter`/`query_batch`.
4704        let Some(pattern) = self.resolve_query_pattern(s, p, o) else {
4705            return;
4706        };
4707        let dict = &self.dict;
4708        // Resolve in fixed-size batches with ONE coalesced dictionary fault per
4709        // batch — the same `prefetch_terms` call `dump_batch` makes, for the
4710        // same reason.
4711        //
4712        // This used to be `Dictionary::prefetch_all` up front, on the argument
4713        // that a full dump reaches every term anyway so the bytes are owed
4714        // either way. That argument only holds for a dump of EVERYTHING, and
4715        // this takes a graph AND a pattern. Measured on `cordis.rete` (801 MB,
4716        // a 417 MB dictionary, six named graphs), lazily opened:
4717        //
4718        //   dump_each(Some(fundingschemes))  417 MB read, 2510 MB peak RSS
4719        //                              →     173 MB read,  619 MB peak RSS
4720        //   dump_each(None) — the EMPTY default graph, which is the first thing
4721        //   `rete export` calls on a quads file:
4722        //                                    415 MB read, 2508 MB peak RSS
4723        //                              →       0 MB read,   95 MB peak RSS
4724        //
4725        // The 2.5 GB comes from asking for every chunk at once: the bulk loader
4726        // coalesces adjacent chunks into one span, so the whole dictionary
4727        // section is materialized three times over before the first triple —
4728        // the fetched span, the per-chunk copies sliced out of it, and the
4729        // decompressed bodies.
4730        //
4731        // Be clear about what this does NOT buy. A dump of the whole default
4732        // graph still faults every chunk, and chunks stay resident once
4733        // faulted, so `rete export` of a single-graph file is unchanged:
4734        // `figshare.rete` (222 MB, a 126 MB dictionary) peaks at 874 MB lazily
4735        // either way. The resident dictionary is the floor there, not this.
4736        //
4737        // Within each window, term resolution is CHUNK-ORDERED, not row-ordered
4738        // (`Dictionary::resolve_window`, bounded-export phase 1): the window's
4739        // ids are grouped by the `(section, chunk)` they live in and each chunk
4740        // is decoded once and its runs walked once, filling every slot that
4741        // chunk owns, then the rows are emitted in the original scan order. Two
4742        // things follow. First, a window's objects — which scatter across the
4743        // object-only section for one subject — touch each chunk a single time
4744        // instead of re-faulting it per row. Second, the per-id re-walk from a
4745        // run's start that the old row-order path did for *every* id collapses
4746        // to one monotonic forward pass per run. The output is byte-for-byte
4747        // identical (same bytes per id, same emission order, same
4748        // all-three-resolved emit rule); the cap is still unlimited, so this is
4749        // structural preparation for the real bound (phase 2), plus a small
4750        // free speedup. The coalesced bulk fault (`prefetch_terms`) is
4751        // unchanged, so the read pattern and peak residency are as before.
4752        const WINDOW: usize = 4096;
4753        // The whole-window coalesced prefetch (`prefetch_terms`) materializes
4754        // every distinct chunk the window touches at once — up to ~2·WINDOW
4755        // chunks for scattered objects. Under a GENEROUS budget that transient
4756        // fits and the coalesced reads are the fast path (small/normal files,
4757        // `--in-memory`, unlimited). Under a TIGHT budget it would blow the cap
4758        // and thrash (insert-then-evict every chunk, then re-fault it in
4759        // `resolve_window`), so skip it: `resolve_window` is chunk-ordered and
4760        // holds one chunk `Arc` at a time (plan §3.5), keeping the window's
4761        // working set to a single chunk regardless of how the objects scatter.
4762        // Output is byte-identical either way — prefetch only warms the cache.
4763        let cap = dict.cache_cap();
4764        let prefetch_window =
4765            cap == u64::MAX || cap >= (WINDOW as u64) * 2 * (DICT_CHUNK_BUDGET as u64);
4766        // Under a finite budget the prefetch also goes in batches of
4767        // PREFETCH_BATCH chunks: one bulk read holds everything it fetched
4768        // (span, slices, decoded bodies) until it is all cached, which for a
4769        // scattered window was thousands of chunks — up to ~1 GB on
4770        // openaire-relation — outside the cache cap. Unlimited keeps one read
4771        // per section (the latency-optimal shape for a remote file).
4772        const PREFETCH_BATCH: usize = 64;
4773        let prefetch_batch = if cap == u64::MAX {
4774            usize::MAX
4775        } else {
4776            PREFETCH_BATCH
4777        };
4778        let mut ids: Vec<(u32, u32, u32)> = Vec::with_capacity(WINDOW);
4779        let mut nodes: Vec<u32> = Vec::with_capacity(WINDOW * 2);
4780        let mut preds: Vec<u32> = Vec::with_capacity(WINDOW);
4781        let mut resolver = crate::dictionary::WindowResolver::default();
4782        let mut flush = |ids: &mut Vec<(u32, u32, u32)>,
4783                         resolver: &mut crate::dictionary::WindowResolver,
4784                         f: &mut F| {
4785            if ids.is_empty() {
4786                return;
4787            }
4788            // Coalesced bulk fault of exactly the chunks this window needs —
4789            // only under a generous budget (see `prefetch_window` above); a
4790            // tight budget faults chunk-by-chunk inside `resolve_window`.
4791            if prefetch_window {
4792                nodes.clear();
4793                preds.clear();
4794                for &(s, p, o) in ids.iter() {
4795                    nodes.push(dict.subject_node(s));
4796                    nodes.push(dict.object_node(o));
4797                    preds.push(p);
4798                }
4799                dict.prefetch_terms_batched(&nodes, &preds, prefetch_batch);
4800            }
4801            dict.resolve_window(ids, resolver, |s, p, o| f(s, p, o));
4802            ids.clear();
4803        };
4804        for t in index.scan_iter(pattern) {
4805            ids.push(t);
4806            if ids.len() == WINDOW {
4807                flush(&mut ids, &mut resolver, &mut f);
4808            }
4809        }
4810        flush(&mut ids, &mut resolver, &mut f);
4811    }
4812
4813    /// What a [`dump_filtered_each`](Self::dump_filtered_each) over this graph
4814    /// and pattern will fetch, **before** it starts — the dump twin of
4815    /// `rete cost`'s query preview.
4816    ///
4817    /// Costed from the tile directories, so it fetches no tile. It does read the
4818    /// dictionary far enough to resolve the bound terms (that is a real,
4819    /// unavoidable cost of the dump too) and to learn whether the graph exists.
4820    pub fn dump_plan(
4821        &self,
4822        graph: Option<&str>,
4823        s: Option<&str>,
4824        p: Option<&str>,
4825        o: Option<&str>,
4826    ) -> DumpPlan {
4827        let index = match graph {
4828            None => Some(&self.index),
4829            Some(g) => self.graph_index(g),
4830        };
4831        let scan = index
4832            .zip(self.resolve_query_pattern(s, p, o))
4833            .map(|(ix, pattern)| ix.scan_plan(pattern));
4834        DumpPlan {
4835            scan,
4836            dictionary_bytes: match &self.v06 {
4837                Some(f) => f.section(crate::v06::kind::DICT_DATA).map_or(0, |r| r.len),
4838                None => self.header.dictionary_len,
4839            },
4840        }
4841    }
4842
4843    /// The **pull** twin of [`Rete::dump_each`](Self::dump_each): every triple of a
4844    /// graph (`None` = default) as a lazy iterator of resolved terms.
4845    ///
4846    /// Same constant-memory scan — one triple decoded and resolved per `next()`,
4847    /// never a `Vec` of the whole graph — but the caller drives it, so it can
4848    /// stop early or be **suspended and resumed** across a foreign-function
4849    /// boundary. That is what the wasm/JS client's batched quad cursor needs:
4850    /// a callback cannot be paused mid-scan to hand control back to JavaScript,
4851    /// an iterator can.
4852    ///
4853    /// The dictionary is NOT prefetched: terms fault in as they are resolved, so
4854    /// stopping after a handful of triples costs a handful of dictionary reads
4855    /// rather than the whole dictionary — which on a lazy remote open of a large
4856    /// file would be gigabytes before the first triple, defeating the point of a
4857    /// pull API. [`dump_each`](Self::dump_each) does not prefetch it whole
4858    /// either; it batches the same per-term faults. Peak memory is
4859    /// O(faulted dictionary chunks + faulted index tiles), *not* O(triples).
4860    pub fn dump_iter(&self, graph: Option<&str>) -> impl Iterator<Item = TermTriple> + '_ {
4861        self.query_iter(graph, None, None, None)
4862    }
4863
4864    /// [`dump_iter`](Self::dump_iter) generalized to a triple **pattern**: the
4865    /// lazy pull form of [`query_in_graph`](Self::query_in_graph). A bound term
4866    /// unknown to the dictionary, or an unknown graph IRI, yields nothing.
4867    ///
4868    /// Same constant-memory guarantee — one triple decoded and resolved per
4869    /// `next()`, never a `Vec` of every match — and the same absence of a
4870    /// dictionary prefetch, so stopping after a handful of matches costs a
4871    /// handful of dictionary reads. `query_in_graph` is the eager twin; prefer
4872    /// this one wherever the consumer can stop early (`ASK`, `LIMIT`, an RDF4J
4873    /// `getStatements` that is about to be sliced).
4874    pub fn query_iter(
4875        &self,
4876        graph: Option<&str>,
4877        s: Option<&str>,
4878        p: Option<&str>,
4879        o: Option<&str>,
4880    ) -> impl Iterator<Item = TermTriple> + '_ {
4881        let index = match graph {
4882            None => Some(&self.index),
4883            Some(g) => self.graph_index(g),
4884        };
4885        index
4886            .zip(self.resolve_query_pattern(s, p, o))
4887            .into_iter()
4888            .flat_map(|(ix, pattern)| ix.scan_iter(pattern))
4889            .filter_map(move |(s, p, o)| {
4890                Some((
4891                    self.dict.subject_term(s)?,
4892                    self.dict.predicate_term(p)?,
4893                    self.dict.object_term(o)?,
4894                ))
4895            })
4896    }
4897
4898    /// One bounded, **resumable** slice of a pattern's matches inside one graph
4899    /// (`None` = default): `(triples, next_cursor, done)`. Start at `cursor = 0`
4900    /// and feed the returned cursor back until `done`.
4901    ///
4902    /// This is [`query_in_graph`](Self::query_in_graph) made streamable across a
4903    /// boundary that cannot hold a Rust borrow — the wasm/JVM one. The engine
4904    /// keeps no iterator alive between calls; the entire resume state is the
4905    /// opaque `u64` (see [`GraphIndex::scan_batch`]), so a client's cursor
4906    /// survives being suspended, and a cursor that is abandoned leaks nothing
4907    /// but a `u64`.
4908    ///
4909    /// The dictionary is faulted **per batch**, not whole: taking ten matches
4910    /// off the front of a 9.8-billion-quad file reads ten matches' worth of
4911    /// terms. That is the difference between `LIMIT 1` answering in bounded time
4912    /// and it reading a 30 GB dictionary first.
4913    ///
4914    /// `max_quads` is a floor (batches end on an a-group boundary), and every
4915    /// call either returns at least one row or reports `done`.
4916    pub fn query_batch(
4917        &self,
4918        graph: Option<&str>,
4919        s: Option<&str>,
4920        p: Option<&str>,
4921        o: Option<&str>,
4922        cursor: u64,
4923        max_quads: usize,
4924    ) -> (Vec<TermTriple>, u64, bool) {
4925        let index = match graph {
4926            None => &self.index,
4927            // An unknown graph IRI is an empty scan, not an error — same as
4928            // `query_in_graph` and `dump_batch`.
4929            Some(g) => match self.graph_index(g) {
4930                Some(i) => i,
4931                None => return (Vec::new(), cursor, true),
4932            },
4933        };
4934        // A bound term the dictionary does not know can match nothing, and the
4935        // index is never touched.
4936        let Some(pattern) = self.resolve_query_pattern(s, p, o) else {
4937            return (Vec::new(), cursor, true);
4938        };
4939        let (ids, next, done) = index.scan_batch(pattern, cursor, max_quads);
4940        // One coalesced dictionary fault for THIS batch — the same call
4941        // `query_in_graph` and `dump_batch` make, and the reason a partial walk
4942        // costs in proportion to what it actually read.
4943        if !ids.is_empty() {
4944            let mut nodes = Vec::with_capacity(ids.len() * 2);
4945            let mut preds = Vec::with_capacity(ids.len());
4946            for &(s, p, o) in &ids {
4947                nodes.push(self.dict.subject_node(s));
4948                nodes.push(self.dict.object_node(o));
4949                preds.push(p);
4950            }
4951            self.dict.prefetch_terms(&nodes, &preds);
4952        }
4953        let triples = ids
4954            .into_iter()
4955            .filter_map(|(s, p, o)| {
4956                Some((
4957                    self.dict.subject_term(s)?,
4958                    self.dict.predicate_term(p)?,
4959                    self.dict.object_term(o)?,
4960                ))
4961            })
4962            .collect();
4963        (triples, next, done)
4964    }
4965
4966    /// Resolve one bounded slice of a graph's triples. Returns
4967    /// `(triples, next_cursor, done)`; start with `cursor = 0` and feed the
4968    /// returned cursor back until `done`.
4969    ///
4970    /// This is the PULL half of the dump, and it exists because [`Rete::dump_each`] is
4971    /// push-based: it drives the scan itself and hands each triple to a
4972    /// callback. A client that wants to pull (a Python generator, a JS async
4973    /// iterator) has to hold the scan's stack between calls — which means a
4974    /// thread (unavailable on Pyodide and in wasm) or a self-referential struct
4975    /// holding an iterator that borrows the `Rete` stored next to it, whose
4976    /// soundness rests on drop order plus an unstated promise that this crate's
4977    /// lazily-faulted caches stay write-once. Neither is worth it for a dump.
4978    ///
4979    /// Instead the scan is made RESUMABLE: SPO tiles are ordered by subject id,
4980    /// so "every triple of subject `sid`, for ascending `sid`" visits exactly the
4981    /// tiles a full scan visits, in the same order, and the whole resume state
4982    /// collapses to one `u32`. No thread, no `unsafe`, no borrow held across
4983    /// calls — and unlike `(offset, limit)` it is O(n) overall rather than
4984    /// O(n²/limit), because nothing is ever re-scanned.
4985    ///
4986    /// `max_quads` is the FLOOR of a batch, not a hard cut: a batch always ends
4987    /// on a subject boundary, so no subject is split across two calls. Work per
4988    /// call is bounded by a probe budget too — a sparse named graph whose few
4989    /// subjects are spread over a huge id space returns early with `done = false`
4990    /// rather than grinding through absent ids, and the caller just asks again.
4991    pub fn dump_batch(
4992        &self,
4993        graph: Option<&str>,
4994        cursor: u32,
4995        max_quads: usize,
4996    ) -> (Vec<TermTriple>, u32, bool) {
4997        /// The first subject id at or after `sid` that any SPO tile can hold, or
4998        /// `None` when `sid` is past the last tile. Tiles ascend by leading
4999        /// range, so this is a binary search; it returns `sid` itself when a tile
5000        /// already covers it (an ordinary "this subject has no triples" step).
5001        fn next_tile_start(tiles: &crate::index::TileList, sid: u32) -> Option<u32> {
5002            let i = tiles.first_max_ge(sid);
5003            (i < tiles.len()).then(|| tiles[i].leading_range().0.max(sid))
5004        }
5005
5006        let index = match graph {
5007            None => self.default_index(),
5008            // An unknown graph IRI is an empty dump, not an error — same as
5009            // `dump_each`.
5010            Some(g) => match self.graph_index(g) {
5011                Some(i) => i,
5012                None => return (Vec::new(), cursor, true),
5013            },
5014        };
5015        // Tile leading ranges live in the section directory, so both the span and
5016        // the gap jumps below are free on a lazy/remote open — no tile is fetched
5017        // to decide where to look.
5018        let tiles = index.tile_sections()[IndexPermutation::Spo.section_index()];
5019        let (Some(first), Some(last)) = (
5020            tiles.first().map(|t| t.leading_range().0),
5021            tiles.last().map(|t| t.leading_range().1),
5022        ) else {
5023            return (Vec::new(), cursor, true); // empty graph
5024        };
5025
5026        let mut sid = cursor.max(first);
5027        let mut probes = max_quads.saturating_mul(4).max(1 << 16);
5028        let mut ids: Vec<(u32, u32, u32)> = Vec::new();
5029        let mut exhausted = false;
5030        while sid <= last && probes > 0 {
5031            probes -= 1;
5032            let hits = index.match_pattern((Some(sid), None, None));
5033            if hits.is_empty() {
5034                // No triples at this id: jump to the next subject the index
5035                // holds (the tile's group directory knows it), instead of
5036                // stepping through absent ids one at a time. That is what keeps
5037                // a quad-index view — whose tile shared with other graphs has
5038                // the conservative range `0..=u32::MAX` — from probing four
5039                // billion ids. Only when that lookup cannot answer (a failed
5040                // fetch, already recorded) does it fall back to the directory:
5041                // if the id falls in a hole BETWEEN tiles, jump to the next
5042                // tile's first id.
5043                match index.next_leading(IndexPermutation::Spo.section_index(), sid) {
5044                    Some(next) if next > sid => {
5045                        sid = next;
5046                        continue;
5047                    }
5048                    None if !index.load_incomplete() => {
5049                        exhausted = true;
5050                        break;
5051                    }
5052                    _ => {}
5053                }
5054                match next_tile_start(tiles, sid) {
5055                    None => {
5056                        exhausted = true;
5057                        break;
5058                    }
5059                    Some(next) if next > sid => {
5060                        sid = next;
5061                        continue;
5062                    }
5063                    Some(_) => {}
5064                }
5065            } else {
5066                ids.extend(hits);
5067            }
5068            if sid == u32::MAX {
5069                exhausted = true;
5070                break;
5071            }
5072            sid += 1;
5073            if ids.len() >= max_quads {
5074                break;
5075            }
5076        }
5077        let done = exhausted || sid > last;
5078
5079        // One coalesced dictionary fault for this batch, then resolve.
5080        // Deliberately NOT `Dictionary::prefetch_all` (what `dump_each` uses):
5081        // on a lazy open that pulls the ENTIRE dictionary — gigabytes on a big
5082        // file — before the first quad, so taking five quads off the front would
5083        // cost as much as a full dump. Per-batch prefetch keeps a partial walk
5084        // proportional to what it actually read.
5085        let dict = self.dictionary();
5086        if !ids.is_empty() {
5087            let mut nodes = Vec::with_capacity(ids.len() * 2);
5088            let mut preds = Vec::with_capacity(ids.len());
5089            for &(s, p, o) in &ids {
5090                nodes.push(dict.subject_node(s));
5091                nodes.push(dict.object_node(o));
5092                preds.push(p);
5093            }
5094            dict.prefetch_terms(&nodes, &preds);
5095        }
5096        let triples = ids
5097            .into_iter()
5098            .filter_map(|(s, p, o)| {
5099                Some((
5100                    dict.subject_term(s)?,
5101                    dict.predicate_term(p)?,
5102                    dict.object_term(o)?,
5103                ))
5104            })
5105            .collect();
5106        (triples, sid, done)
5107    }
5108
5109    /// How many named graphs this dataset has. On a lazy ranged open this
5110    /// reads only the section's leading count varint — never the graphs.
5111    pub fn named_graph_count(&self) -> usize {
5112        self.named_graphs.count()
5113    }
5114
5115    /// The `i`-th named graph's quad count, when the file records it: a
5116    /// quad-index file keeps it in its graph directory, so this reads one
5117    /// directory block and never the graph's index. `None` for a per-graph
5118    /// (NAMED_GRAPHS section) file, or past the last graph.
5119    pub fn named_graph_quad_count(&self, i: usize) -> Option<u64> {
5120        match &self.named_graphs {
5121            NamedGraphsSlot::Quad(q) => q.quad_count_at(i),
5122            _ => None,
5123        }
5124    }
5125
5126    /// The `i`-th named graph as an **owned** index that is not memoised,
5127    /// when the file can make one cheaply: a quad-index file's graph of at
5128    /// most [`TRANSIENT_GRAPH_QUADS`] quads. A walk over every graph (`GRAPH
5129    /// ?g`, the union default graph, `query_quads`) evaluates each such graph
5130    /// and drops it, instead of keeping one index per graph for the life of
5131    /// the open — 1.5 M of them on the OpenCitations sample. `None` for a
5132    /// larger graph, a per-graph file, or a failed read: use
5133    /// [`named_graph_at`](Self::named_graph_at) then.
5134    pub fn named_graph_transient(&self, i: usize) -> Option<GraphIndex> {
5135        match &self.named_graphs {
5136            NamedGraphsSlot::Quad(q) => q.transient_view(i, TRANSIENT_GRAPH_QUADS),
5137            _ => None,
5138        }
5139    }
5140
5141    /// The ordinals (ascending, as [`named_graph_at`](Self::named_graph_at)
5142    /// counts them) of the named graphs holding at least one quad whose
5143    /// subject is one of `subjects` (subject ids), for a quad-index file: one
5144    /// sequential pass over its graph-leading SPO tiles, never a view per
5145    /// graph. `None` for a per-graph file, which has no such pass, or on a
5146    /// failed read (recorded, see [`index_incomplete`](Self::index_incomplete)).
5147    pub(crate) fn named_graphs_with_subjects(
5148        &self,
5149        subjects: &std::collections::BTreeSet<u32>,
5150    ) -> Option<Vec<usize>> {
5151        match &self.named_graphs {
5152            NamedGraphsSlot::Quad(q) => q.graphs_with_subjects(subjects),
5153            _ => None,
5154        }
5155    }
5156
5157    /// Deduplicated quads across every named graph, when the file records it
5158    /// (a quad-index file, from its preamble: one small read). `None` for a
5159    /// per-graph file — there it is the header's `quad_count` minus the
5160    /// default graph's triples.
5161    pub fn named_quad_count(&self) -> Option<u64> {
5162        match &self.named_graphs {
5163            NamedGraphsSlot::Quad(q) => Some(q.quad_count()),
5164            _ => None,
5165        }
5166    }
5167
5168    /// How many triples `index` holds, for sizing a pattern's tile estimate:
5169    /// a named graph's own count when it is known without a tile read (a
5170    /// quad-index graph's directory count, a resident per-graph index), else
5171    /// the header's total. The default graph keeps the total: its size is not
5172    /// recorded in the header, and the planner's thresholds were tuned on it.
5173    pub(crate) fn index_triples(&self, index: &GraphIndex) -> u64 {
5174        index.size_hint().unwrap_or(self.header().quad_count)
5175    }
5176
5177    /// The `i`-th named graph's IRI (stored order). On a lazy ranged open this
5178    /// walks entry HEADERS up to `i` — it never decodes a graph's index.
5179    pub fn named_graph_name_at(&self, i: usize) -> Option<&str> {
5180        self.named_graphs.name_at(&self.dict, i, false)
5181    }
5182
5183    /// The `i`-th named graph as `(iri, index)`. On a lazy ranged open the
5184    /// index is fetched and decoded on first access and memoised; check
5185    /// [`index_incomplete`](Self::index_incomplete) after evaluating.
5186    pub fn named_graph_at(&self, i: usize) -> Option<(&str, &GraphIndex)> {
5187        self.named_graphs.graph_at(&self.dict, i, false)
5188    }
5189
5190    /// [`named_graph_name_at`](Self::named_graph_name_at) under a declared
5191    /// demand: `exhaustive = true` tells a lazy walk the caller will visit
5192    /// every graph, so it may read the section in bulk chunks. Purely a fetch
5193    /// strategy — results are identical either way. Only the SPARQL evaluator
5194    /// sets it, from shapes where full consumption is provable.
5195    pub(crate) fn named_graph_name_at_demand(&self, i: usize, exhaustive: bool) -> Option<&str> {
5196        self.named_graphs.name_at(&self.dict, i, exhaustive)
5197    }
5198
5199    /// [`named_graph_at`](Self::named_graph_at) under a declared demand: see
5200    /// [`named_graph_name_at_demand`](Self::named_graph_name_at_demand).
5201    pub(crate) fn named_graph_at_demand(
5202        &self,
5203        i: usize,
5204        exhaustive: bool,
5205    ) -> Option<(&str, &GraphIndex)> {
5206        self.named_graphs.graph_at(&self.dict, i, exhaustive)
5207    }
5208
5209    /// IRIs of the named graphs in this dataset (the default graph is unnamed).
5210    /// On a lazy ranged open this walks the whole directory (headers only) —
5211    /// prefer [`named_graph_count`](Self::named_graph_count) when the number
5212    /// is all that's needed.
5213    pub fn graph_names(&self) -> Vec<&str> {
5214        (0..self.named_graphs.count())
5215            .filter_map(|i| self.named_graphs.name_at(&self.dict, i, false))
5216            .collect()
5217    }
5218
5219    /// The permutation index of a named graph, or `None` if absent.
5220    pub fn graph_index(&self, iri: &str) -> Option<&GraphIndex> {
5221        self.named_graphs.find(&self.dict, iri)
5222    }
5223
5224    /// Match a triple pattern in dictionary-ID space (subject/predicate/object
5225    /// IDs), returning integer triples — the fast path used by the BGP engine.
5226    pub fn match_ids(
5227        &self,
5228        pattern: (Option<u32>, Option<u32>, Option<u32>),
5229    ) -> Vec<(u32, u32, u32)> {
5230        self.index.match_pattern(pattern)
5231    }
5232
5233    /// All `(subject_node, object_node)` pairs for a predicate, as unified node
5234    /// IDs — no term resolution. The fast path for graph traversal.
5235    pub fn predicate_pairs(&self, predicate: &str) -> Vec<(u32, u32)> {
5236        let pid = match self.dict.predicate_id(predicate) {
5237            Some(p) => p,
5238            None => return Vec::new(),
5239        };
5240        self.index
5241            .match_pattern((None, Some(pid), None))
5242            .into_iter()
5243            .map(|(s, _p, o)| (self.dict.subject_node(s), self.dict.object_node(o)))
5244            .collect()
5245    }
5246
5247    /// Open via a [`RangeReader`], fetching only the header and the named
5248    /// section ranges — never a linear scan of the whole resource. A full query
5249    /// open touches at most 4 ranges (header, dictionary, index, pyramid-meta).
5250    pub fn open_ranged<R: RangeReader>(reader: &R) -> Result<Self, FileError> {
5251        let head = reader.read_at(0, (HEADER_LEN as u64).min(reader.len()))?;
5252        if crate::v06::open::is_v06(&head) {
5253            // The eager path reads the whole image — the rest of it, in one
5254            // more request — and a 0x06 one then opens from memory.
5255            let mut all = head;
5256            let at = all.len() as u64;
5257            if reader.len() > at {
5258                all.extend_from_slice(&reader.read_at(at, reader.len() - at)?);
5259            }
5260            return Self::open(&all);
5261        }
5262        let header = Header::from_bytes(&head)?;
5263
5264        let dict_bytes = reader.read_at(header.dictionary_offset, header.dictionary_len)?;
5265        let dict = decode_dictionary_container(&dict_bytes, header.dict_codec)?;
5266
5267        check_named_layout(&header)?;
5268        let index_bytes = reader.read_at(header.root_dir_offset, header.root_dir_len)?;
5269        let (index, _) = decode_root_index_container(&index_bytes, &header)?;
5270        let index_section_ranges = decode_index_section_ranges(
5271            &index_bytes,
5272            header.root_dir_offset,
5273            header.perms,
5274            header.index_container_sections(),
5275        )?;
5276
5277        let pyramid = PyramidSlot::Resident(if header.pyramid_meta_len > 0 {
5278            let mb = reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?;
5279            Some(
5280                PyramidMeta::decode(&mb)
5281                    .map_err(|_| FileError::Container("malformed pyramid meta"))?,
5282            )
5283        } else {
5284            None
5285        });
5286
5287        // Fetch the whole TEXT_INDEX section resident (this opener does one range
5288        // read per section; the lazy opener below is the one that defers it).
5289        let (ti_off, ti_len) = header.text_index_payload();
5290        let text_index_bytes = if ti_len > 0 {
5291            Some(reader.read_at(ti_off, ti_len)?)
5292        } else {
5293            None
5294        };
5295        let text_index = resident_text_index_slot(text_index_bytes.as_deref(), header.block_codec)?;
5296        // LABEL_INDEX and SCHEMA: one range read each, held resident like the
5297        // other sections of this opener.
5298        let labels = SectionSlot::Resident(header.label_index_section().and_then(|s| {
5299            let b = reader.read_at(s.offset, s.length).ok()?;
5300            crate::label_index::LabelIndex::from_section(&b, header.block_codec).ok()
5301        }));
5302        let schema = SectionSlot::Resident(read_schema_with_header(reader, &header));
5303
5304        let named_graphs = NamedGraphsSlot::resident(
5305            &header,
5306            |off, len| Ok(reader.read_at(off, len)?),
5307            || {
5308                let nb = reader.read_at(header.named_graphs_offset, header.named_graphs_len)?;
5309                decode_named_graphs(&nb, header.block_codec, header.perms)
5310            },
5311        )?;
5312
5313        // The metadata section (Dataset Card) is deliberately NOT fetched here:
5314        // a ranged query open keeps to its small range budget. Use `Rete::open`
5315        // (or a dedicated card fetch) when the card is actually needed.
5316        let tile_ranges =
5317            tile_file_ranges(&index_bytes, header.root_dir_offset, &index_section_ranges);
5318        Ok(Self {
5319            header,
5320            dict,
5321            index,
5322            index_section_ranges,
5323            tile_ranges,
5324            pyramid,
5325            text_index,
5326            labels,
5327            schema,
5328            named_graphs,
5329            metadata: Vec::new(),
5330            service_client: None,
5331            service_error: std::sync::Mutex::new(None),
5332            spill_threshold: std::sync::atomic::AtomicU64::new(u64::MAX),
5333            v06: None,
5334        })
5335    }
5336
5337    /// Open a `0x06` file whose first bytes are `first`: the header and root
5338    /// come from them; the dictionary and index are two-level directories whose
5339    /// leaves fault on demand; text, labels, schema, pyramid and named graphs
5340    /// are deferred. `budget` applies the default memory budget (the lazy
5341    /// remote path) as [`open_ranged_lazy`](Self::open_ranged_lazy) does.
5342    fn open_v06(
5343        reader: std::sync::Arc<dyn RangeReader + Send + Sync>,
5344        first: &[u8],
5345        budget: bool,
5346    ) -> Result<Self, FileError> {
5347        let f = std::sync::Arc::new(crate::v06::open::V06File::open(reader.clone(), first)?);
5348        let header = crate::v06::logical_header(&f.h);
5349        let rc = reader.concurrency();
5350        let dict = f.dictionary([true; 4])?;
5351        let index = f.index(rc)?;
5352        let (pyramid, text_index, labels, schema) = v06_slots(&f);
5353        let named_graphs = match f.quad(rc) {
5354            Some(q) => NamedGraphsSlot::Quad(q?),
5355            None => NamedGraphsSlot::Resident(Vec::new()),
5356        };
5357        let rete = Self {
5358            header,
5359            dict,
5360            index,
5361            index_section_ranges: [ByteRange::default(); NUM_PERMS],
5362            tile_ranges: Default::default(),
5363            pyramid,
5364            text_index,
5365            labels,
5366            schema,
5367            named_graphs,
5368            metadata: Vec::new(),
5369            service_client: None,
5370            service_error: std::sync::Mutex::new(None),
5371            spill_threshold: std::sync::atomic::AtomicU64::new(u64::MAX),
5372            v06: Some(f),
5373        };
5374        #[cfg(not(target_arch = "wasm32"))]
5375        if budget {
5376            rete.set_memory_budget(match crate::sysmem::default_query_budget_mb() {
5377                0 => None,
5378                mb => Some(mb << 20),
5379            });
5380        }
5381        #[cfg(target_arch = "wasm32")]
5382        let _ = budget;
5383        Ok(rete)
5384    }
5385
5386    /// The open `0x06` file (header, root, reader), `None` for a `0x05` open.
5387    pub fn v06(&self) -> Option<&crate::v06::open::V06File> {
5388        self.v06.as_deref()
5389    }
5390
5391    /// Open via an **owned** [`RangeReader`] with lazy tile faulting (tiled
5392    /// v0.2 files): fetches the header, dictionary, pyramid meta, named graphs,
5393    /// and each permutation's tile **directory** — but no default-graph tile
5394    /// payloads. Tiles fault in (one range request each) the first time a scan
5395    /// touches them, so a selective SPARQL query fetches O(touched tiles)
5396    /// bytes instead of the whole index.
5397    ///
5398    /// **Failure contract:** scans are infallible by design, so a failed tile
5399    /// fetch yields an empty tile and sets a sticky flag — after evaluating,
5400    /// callers MUST check [`index_incomplete`](Self::index_incomplete) and
5401    /// surface an error instead of the (possibly partial) results.
5402    pub fn open_ranged_lazy<R: RangeReader + Send + Sync + 'static>(
5403        reader: R,
5404    ) -> Result<Self, FileError> {
5405        Self::open_ranged_lazy_with(reader, crate::v06::FIRST_READ)
5406    }
5407
5408    /// [`open_ranged_lazy`](Self::open_ranged_lazy) with an explicit first-read
5409    /// size (at least the 1 KiB a `0x05` header needs). The default is
5410    /// [`crate::v06::FIRST_READ`]; a smaller one keeps a byte-accounting test
5411    /// of a tiny `0x05` file from reading the whole file up front.
5412    pub fn open_ranged_lazy_with<R: RangeReader + Send + Sync + 'static>(
5413        reader: R,
5414        first_read: u64,
5415    ) -> Result<Self, FileError> {
5416        // One first read — 64 KiB, or the whole file when smaller — decides the
5417        // format, and every later read inside it is served from memory.
5418        let first_len = first_read.max(HEADER_LEN as u64).min(reader.len());
5419        let first: std::sync::Arc<[u8]> = std::sync::Arc::from(reader.read_at(0, first_len)?);
5420        #[cfg(feature = "v06-parity")]
5421        if !crate::v06::open::is_v06(&first) {
5422            let all = reader.read_at(0, reader.len())?;
5423            if let Some(v6) = parity_image(&all) {
5424                let r: std::sync::Arc<dyn RangeReader + Send + Sync> =
5425                    std::sync::Arc::new(crate::reader::BytesReader(std::sync::Arc::from(v6)));
5426                let head = r.read_at(0, crate::v06::FIRST_READ.min(r.len()))?;
5427                return Self::open_v06(r, &head, true);
5428            }
5429        }
5430        let reader = crate::reader::PrefixReader::new(reader, first.clone());
5431        if crate::v06::open::is_v06(&first) {
5432            let r: std::sync::Arc<dyn RangeReader + Send + Sync> = std::sync::Arc::new(reader);
5433            return Self::open_v06(r, &first, true);
5434        }
5435        let head = reader.read_at(0, HEADER_LEN as u64)?;
5436        let header = Header::from_bytes(&head)?;
5437        check_named_layout(&header)?;
5438        let reader = std::sync::Arc::new(reader);
5439        // Captured before the loader closures take the Arc: the reader's
5440        // concurrent-range fan-out, stamped onto the index for the planner.
5441        let read_concurrency = reader.concurrency();
5442
5443        // Lazily-chunked dictionary: locate the four sections, fetch each
5444        // section's header + restart table + chunk directory (small), and
5445        // fault the chunk bodies in on first term lookup.
5446        let open_dict = || ranged_chunked_dictionary(&reader, &header, [true; 4]);
5447
5448        // Locate the six index section payloads (container framing only) and
5449        // fetch just their tile directories — shared with the per-named-graph
5450        // lazy opener, which opens a large graph's container the same way.
5451        let reader_dyn: std::sync::Arc<dyn RangeReader + Send + Sync> = reader.clone();
5452        let open_index = || {
5453            open_index_container_lazy(
5454                &reader_dyn,
5455                ByteRange {
5456                    offset: header.root_dir_offset,
5457                    len: header.root_dir_len,
5458                },
5459                header.block_codec,
5460                header.has_tile_synopsis(),
5461                read_concurrency,
5462                header.perms,
5463                header.index_container_sections(),
5464            )
5465        };
5466
5467        // Each of the two is a chain of small dependent reads (a container's
5468        // sections are found by walking their length prefixes), ~8 round trips
5469        // apiece on a real file, and the header already says where both
5470        // containers start. So a reader that can keep several reads in flight
5471        // walks them at the same time: the open costs the longer chain, not
5472        // the sum. Same reads, same result; only their overlap changes.
5473        let (dict, index) = open_side_by_side(read_concurrency, open_dict, open_index);
5474        let dict = dict?;
5475        let (index, index_section_ranges, tile_ranges) = index?;
5476
5477        // The pyramid meta is large on real graphs (tens of MB) and SPARQL never
5478        // reads it, so defer its fetch: it faults in only if `pyramid()` is
5479        // called (community / pyramid_tree / inspect queries).
5480        let pyramid = ranged_pyramid_slot(&reader, &header);
5481
5482        // The TEXT_INDEX section is also deferred: a text search faults the token
5483        // table on first call (the leading varint then its compressed bytes), then
5484        // fetches individual posting lists by `(offset, len)` — never the whole
5485        // postings blob. A SPARQL query, which never searches, pays nothing.
5486        let text_index = ranged_text_index_slot(&reader, &header);
5487
5488        // LABEL_INDEX: the first prefix search reads its head and walks down.
5489        // SCHEMA: read once, on first use — a stats question, or the planner
5490        // ordering a join that needs a measured selectivity.
5491        let labels = ranged_label_slot(&reader, &header);
5492        let schema = ranged_schema_slot(&reader, &header);
5493
5494        // Named graphs are the last eagerly-fetched section standing on this
5495        // path — and on a many-graph file they dwarf everything else (67 MB
5496        // fetched and ~32k graph indexes decoded before the first query, on a
5497        // file whose queries then touch a handful of graphs). Defer them like
5498        // the pyramid and text index: nothing is read at open; a query walks
5499        // the directory and decodes only the graphs it touches.
5500        let named_graphs = if let Some(q) = header.quad_index_section() {
5501            // The quad index: nothing read at open either; the preamble on the
5502            // first named-graph touch, then only the routing parts and tiles
5503            // a query uses.
5504            NamedGraphsSlot::Quad(crate::quad::QuadNamed::new(
5505                reader_dyn,
5506                ByteRange {
5507                    offset: q.offset,
5508                    len: q.length,
5509                },
5510                header.block_codec,
5511                header.perms,
5512                read_concurrency,
5513            ))
5514        } else if header.named_graphs_len > 0 {
5515            NamedGraphsSlot::Lazy(LazyNamedGraphs::new(
5516                reader_dyn,
5517                ByteRange {
5518                    offset: header.named_graphs_offset,
5519                    len: header.named_graphs_len,
5520                },
5521                header.block_codec,
5522                header.has_tile_synopsis(),
5523                read_concurrency,
5524                header.perms,
5525            ))
5526        } else {
5527            NamedGraphsSlot::Resident(Vec::new())
5528        };
5529
5530        let rete = Self {
5531            header,
5532            dict,
5533            index,
5534            index_section_ranges,
5535            tile_ranges,
5536            pyramid,
5537            text_index,
5538            labels,
5539            schema,
5540            named_graphs,
5541            metadata: Vec::new(),
5542            service_client: None,
5543            service_error: std::sync::Mutex::new(None),
5544            spill_threshold: std::sync::atomic::AtomicU64::new(u64::MAX),
5545            v06: None,
5546        };
5547        // A lazy open is the path for files too big to read whole, and the
5548        // one a long-lived session (relay, Python/R/Java clients, `rete serve`)
5549        // keeps resident across queries. Unbounded, its dictionary and tile
5550        // caches grow with everything any query ever touched: a GROUP BY over
5551        // a 9.5-billion-triple file ran a 16 GB container out of memory. Cap
5552        // them by default; a caller that wants another figure (or unlimited)
5553        // calls `set_memory_budget` again. The default is sized from the
5554        // memory this process can still use (`RETE_QUERY_BUDGET_MB` overrides
5555        // it; see `crate::sysmem`): a fixed 4096 MiB did not fit a 4 GiB
5556        // container. Not on wasm32, whose 4 GiB address space makes the default
5557        // meaningless and whose callers bound their own range budgets.
5558        #[cfg(not(target_arch = "wasm32"))]
5559        rete.set_memory_budget(match crate::sysmem::default_query_budget_mb() {
5560            0 => None,
5561            mb => Some(mb << 20),
5562        });
5563        Ok(rete)
5564    }
5565
5566    /// Did any lazy fetch (index tile or dictionary chunk) fail since this
5567    /// `Rete` was opened? When true, query results may be silently incomplete —
5568    /// callers using [`Rete::open_ranged_lazy`] must check this after
5569    /// evaluating and turn it into an error.
5570    pub fn index_incomplete(&self) -> bool {
5571        self.index.load_incomplete()
5572            || self.dict.load_incomplete()
5573            || self.named_graphs.load_incomplete()
5574    }
5575
5576    /// Forget recorded lazy-fetch failures — the start-of-evaluation reset for
5577    /// a RESIDENT session (a browser worker holding one `Rete` across many
5578    /// queries): it makes [`index_incomplete`](Self::index_incomplete) a
5579    /// per-query verdict instead of a per-open one, so a single transient
5580    /// network failure no longer fails every subsequent query on the session.
5581    /// Sound because failed tiles/chunks are never cached — the next
5582    /// evaluation simply retries the fetch.
5583    pub fn reset_load_failures(&self) {
5584        self.index.reset_load_failure();
5585        self.dict.reset_load_failure();
5586        self.named_graphs.reset_load_failures();
5587    }
5588
5589    /// Bound peak resident memory to roughly `budget` bytes by capping the
5590    /// evictable dictionary chunk cache and the default-graph index tile cache
5591    /// (bounded-export phase 2). `None` or `u64::MAX` means unlimited — nothing
5592    /// is evicted and residency is identical to a plain open, which is what
5593    /// `--in-memory` and every query/wasm path use.
5594    ///
5595    /// Sound at any cap: the dictionary and the default-graph index both fault
5596    /// their chunks/tiles through a loader, so an evicted body is simply
5597    /// re-faulted on its next touch; the export dump reads each needed body
5598    /// while holding it (the cache `Arc` handout invariant), so eviction never
5599    /// drops bytes in use. Named-graph indexes are **not** capped here — a small
5600    /// resident graph has no loader and could never reload an evicted tile — so
5601    /// they stay unlimited and are released whole per slot instead (see
5602    /// [`release_named_graph`](Self::release_named_graph)).
5603    ///
5604    /// The open's **fixed** heap — chunk and tile directories, the named-graph
5605    /// directory walked so far ([`fixed_overhead`](Self::fixed_overhead)) — is
5606    /// paid whatever the caches hold, so it comes out of the dictionary share:
5607    /// the caps plus the fixed heap stay within `budget`. It grows with the
5608    /// file's chunk/tile/graph count (~18 MB of directories on the 14.75 GiB
5609    /// openaire-relation, 88 MB with its tile directories; 224 MB on a pre-#198
5610    /// epfl-infoscience whose chunk keys are whole boundary terms). Calling
5611    /// this again after a walk of the named-graph directory (as `rete export`
5612    /// does once it has listed the graph slots) re-subtracts the larger figure.
5613    /// The dictionary share never drops below its 64 MiB floor, which is the
5614    /// only way the total can be exceeded: a file whose directories alone
5615    /// approach the budget.
5616    ///
5617    /// Returns the split actually applied (dictionary share net of the fixed
5618    /// heap), for `RETE_OPEN_DEBUG` logging.
5619    pub fn set_memory_budget(&self, budget: Option<u64>) -> MemoryBudget {
5620        let total = budget.unwrap_or(u64::MAX);
5621        self.set_spill_threshold(budget.map(spill_threshold_for));
5622        let mut split = split_memory_budget(total);
5623        let fixed = if total == u64::MAX {
5624            0
5625        } else {
5626            self.fixed_overhead().total()
5627        };
5628        if split.dict_cache != u64::MAX {
5629            split.dict_cache = split
5630                .dict_cache
5631                .saturating_sub(fixed)
5632                .max(MIN_DICT_CACHE_CAP);
5633        }
5634        self.dict.set_cache_cap(split.dict_cache);
5635        self.index.set_cache_cap(split.tile_cache);
5636        // Large named graphs (opened lazily, loader-bearing) get the same tile
5637        // cap; small resident ones stay unlimited (bounded by size + per-slot
5638        // release). Combined with `release_named_graph`, a many-graph dump holds
5639        // one graph's tiles at a time, each capped.
5640        match &self.named_graphs {
5641            NamedGraphsSlot::Lazy(lazy) => lazy.set_tile_cap(split.tile_cache),
5642            // Every quad-index view, and the tiles they share, re-fault
5643            // through loaders — on every open path — so they take the cap too.
5644            NamedGraphsSlot::Quad(q) => q.set_tile_cap(split.tile_cache),
5645            NamedGraphsSlot::Resident(_) => {}
5646        }
5647        if std::env::var("RETE_OPEN_DEBUG").is_ok() {
5648            eprintln!(
5649                "[memory-budget] total={} dict_cache={} tile_cache={} block_cache={} fixed={:.1}MiB",
5650                if total == u64::MAX {
5651                    "unlimited".to_string()
5652                } else {
5653                    format!("{}MiB", total >> 20)
5654                },
5655                fmt_cap(split.dict_cache),
5656                fmt_cap(split.tile_cache),
5657                fmt_cap(split.block_cache),
5658                fixed as f64 / (1u64 << 20) as f64,
5659            );
5660        }
5661        split
5662    }
5663
5664    /// How many bytes a query's `COUNT(DISTINCT …)` keeps in memory before it
5665    /// writes sorted runs to the temporary directory (`TMPDIR`) and counts by
5666    /// merging them; the count is exact either way. `u64::MAX` (the default of
5667    /// an eager open) never spills. [`set_memory_budget`](Self::set_memory_budget)
5668    /// sets it to an eighth of the budget, so a lazy open spills past 512 MiB
5669    /// by default.
5670    pub fn spill_threshold(&self) -> u64 {
5671        self.spill_threshold
5672            .load(std::sync::atomic::Ordering::Relaxed)
5673    }
5674
5675    /// Set [`spill_threshold`](Self::spill_threshold) without touching the
5676    /// caches — for an eager (resident) open, whose caches cannot be capped.
5677    /// `None` = never spill.
5678    pub fn set_spill_threshold(&self, bytes: Option<u64>) {
5679        self.spill_threshold.store(
5680            bytes.unwrap_or(u64::MAX),
5681            std::sync::atomic::Ordering::Relaxed,
5682        );
5683    }
5684
5685    /// The dictionary chunk cache's observability counters (decoded chunks and
5686    /// bytes, hits/misses, evictions, resident bytes) — for the bounded-export
5687    /// work-accounting tests and `RETE_OPEN_DEBUG`.
5688    pub fn dict_cache_stats(&self) -> crate::chunk_cache::CacheStats {
5689        self.dict.cache_stats()
5690    }
5691
5692    /// Heap bytes this open holds outside the bounded caches — the dictionary
5693    /// chunk directories, the index tile directories and the named-graph
5694    /// directory — for `RETE_OPEN_DEBUG` / `RETE_MEM_TRACE` accounting.
5695    pub fn fixed_overhead(&self) -> FixedOverhead {
5696        FixedOverhead {
5697            dict_directory: self.dict.directory_bytes(),
5698            index_directory: self.index.directory_bytes(),
5699            named_graphs: self.named_graphs.directory_bytes(),
5700        }
5701    }
5702
5703    /// The default-graph index tile cache's observability counters.
5704    pub fn index_cache_stats(&self) -> crate::chunk_cache::CacheStats {
5705        self.index.cache_stats()
5706    }
5707
5708    /// The current dictionary chunk-cache byte cap (`u64::MAX` = unlimited).
5709    pub fn dict_cache_cap(&self) -> u64 {
5710        self.dict.cache_cap()
5711    }
5712
5713    /// Release a named graph's decoded index after its slot has been exported,
5714    /// dropping its `GraphIndex` (and, with it, that graph's decoded tiles and
5715    /// its own tile cache) so a many-graph dump does not accumulate one resident
5716    /// index per graph — the next linear floor on many-graph files once the
5717    /// dictionary is bounded (plan §6). A no-op for the default graph, an
5718    /// unknown IRI, a resident (in-memory/eager) open, or a graph never opened.
5719    ///
5720    /// Takes `&mut self`: the export loop finishes one slot's dump (which only
5721    /// borrows `&self`) before releasing, so no outstanding borrow of the index
5722    /// exists. Correct because a released index is simply re-opened (headers +
5723    /// tile directory) on a later access — the same lazy contract as a chunk
5724    /// that was never faulted.
5725    pub fn release_named_graph(&mut self, iri: &str) {
5726        match &mut self.named_graphs {
5727            NamedGraphsSlot::Lazy(lazy) => lazy.release(iri),
5728            NamedGraphsSlot::Quad(q) => q.release(&self.dict, iri),
5729            NamedGraphsSlot::Resident(_) => {}
5730        }
5731    }
5732
5733    fn resolve_query_pattern(
5734        &self,
5735        s: Option<&str>,
5736        p: Option<&str>,
5737        o: Option<&str>,
5738    ) -> Option<Pattern> {
5739        let sid = match s {
5740            Some(t) => match self.dict.subject_id(t) {
5741                Some(id) => Some(id),
5742                None => return None,
5743            },
5744            None => None,
5745        };
5746        let pid = match p {
5747            Some(t) => match self.dict.predicate_id(t) {
5748                Some(id) => Some(id),
5749                None => return None,
5750            },
5751            None => None,
5752        };
5753        let oid = match o {
5754            Some(t) => match self.dict.object_id(t) {
5755                Some(id) => Some(id),
5756                None => return None,
5757            },
5758            None => None,
5759        };
5760        Some((sid, pid, oid))
5761    }
5762
5763    /// Evaluate a triple pattern and include the file/index provenance for every
5764    /// matched result. A bound term that is unknown to the dictionary yields no
5765    /// matches.
5766    pub fn query_with_provenance(
5767        &self,
5768        s: Option<&str>,
5769        p: Option<&str>,
5770        o: Option<&str>,
5771    ) -> Vec<TripleProvenance> {
5772        let pattern = match self.resolve_query_pattern(s, p, o) {
5773            Some(pattern) => pattern,
5774            None => return Vec::new(),
5775        };
5776
5777        let index_permutation = GraphIndex::best_permutation_in(self.header.perms, pattern);
5778        let si = index_permutation.section_index();
5779        let (dictionary_range, index_range, index_section_range, pyramid_range) = match &self.v06 {
5780            Some(f) => {
5781                use crate::v06::kind;
5782                let sec = |k| f.section(k).unwrap_or_default();
5783                let perm = f
5784                    .root
5785                    .unit(crate::v06::tag::INDEX)
5786                    .and_then(|u| crate::v06::decode_index_root(u).ok())
5787                    .and_then(|v| v.into_iter().find(|p| p.si == si))
5788                    .map(|p| ByteRange {
5789                        offset: sec(kind::INDEX_DATA).offset + p.data_off,
5790                        len: p.data_len,
5791                    })
5792                    .unwrap_or_default();
5793                (
5794                    sec(kind::DICT_DATA),
5795                    sec(kind::INDEX_DATA),
5796                    perm,
5797                    f.section(kind::PYRAMID),
5798                )
5799            }
5800            None => (
5801                ByteRange {
5802                    offset: self.header.dictionary_offset,
5803                    len: self.header.dictionary_len,
5804                },
5805                ByteRange {
5806                    offset: self.header.root_dir_offset,
5807                    len: self.header.root_dir_len,
5808                },
5809                self.index_section_ranges[si],
5810                (self.header.pyramid_meta_len > 0).then_some(ByteRange {
5811                    offset: self.header.pyramid_meta_offset,
5812                    len: self.header.pyramid_meta_len,
5813                }),
5814            ),
5815        };
5816
5817        let tiles = &self.tile_ranges[si];
5818        let v06_tiles = self.v06.is_some().then(|| self.index.tile_sections()[si]);
5819        self.index
5820            .match_pattern(pattern)
5821            .into_iter()
5822            .filter_map(|(s, p, o)| {
5823                let terms = (
5824                    self.dict.subject_term(s)?,
5825                    self.dict.predicate_term(p)?,
5826                    self.dict.object_term(o)?,
5827                );
5828                // The physical tile holding this match: the one whose
5829                // leading-id range covers the match's permuted leading id.
5830                let a = index_permutation.forward((s, p, o)).0;
5831                let (tile, tile_range) = if let Some(tl) = v06_tiles {
5832                    let ti = tl.first_max_ge(a);
5833                    match (ti < tl.len() && tl[ti].leading_range().0 <= a)
5834                        .then(|| tl.range(ti))
5835                        .flatten()
5836                    {
5837                        Some((offset, len)) => (
5838                            Some(format!("{}/{ti}", index_permutation.name())),
5839                            Some(ByteRange { offset, len }),
5840                        ),
5841                        None => (None, None),
5842                    }
5843                } else {
5844                    let ti = tiles.partition_point(|&(_, max_a, _)| max_a < a);
5845                    match tiles.get(ti) {
5846                        Some(&(min_a, _, range)) if min_a <= a => (
5847                            Some(format!("{}/{ti}", index_permutation.name())),
5848                            Some(range),
5849                        ),
5850                        _ => (None, None),
5851                    }
5852                };
5853                Some(TripleProvenance {
5854                    terms,
5855                    ids: (s, p, o),
5856                    graph: None,
5857                    matched_pattern: pattern,
5858                    index_permutation,
5859                    dictionary_range,
5860                    index_range,
5861                    index_section_range,
5862                    pyramid_range,
5863                    tile,
5864                    tile_range,
5865                })
5866            })
5867            .collect()
5868    }
5869
5870    /// Evaluate a triple pattern given as optional term strings, returning
5871    /// matching triples resolved back to terms. A bound term that is unknown to
5872    /// the dictionary yields no matches.
5873    pub fn query(&self, s: Option<&str>, p: Option<&str>, o: Option<&str>) -> Vec<TermTriple> {
5874        self.query_with_provenance(s, p, o)
5875            .into_iter()
5876            .map(|m| m.terms)
5877            .collect()
5878    }
5879
5880    /// Match a triple pattern **within a single graph** — `None` is the default
5881    /// graph, `Some(iri)` a named graph — resolving matches to canonical terms.
5882    /// This is [`Rete::query`] (default-graph only) generalized to any graph: the
5883    /// graph-scoped primitive a quad-aware consumer (e.g. an RDF4J `Sail`'s
5884    /// `getStatements`) needs. An unknown graph IRI, or a bound term absent from
5885    /// the shared dictionary, yields an empty result. All graphs share one
5886    /// dictionary, so the pattern resolves once against that ID space.
5887    pub fn query_in_graph(
5888        &self,
5889        graph: Option<&str>,
5890        s: Option<&str>,
5891        p: Option<&str>,
5892        o: Option<&str>,
5893    ) -> Vec<TermTriple> {
5894        let pattern = match self.resolve_query_pattern(s, p, o) {
5895            Some(pattern) => pattern,
5896            None => return Vec::new(),
5897        };
5898        let index = match graph {
5899            None => &self.index,
5900            Some(g) => match self.graph_index(g) {
5901                Some(i) => i,
5902                None => return Vec::new(),
5903            },
5904        };
5905        self.resolve_matches(index.match_pattern(pattern))
5906    }
5907
5908    /// Resolve matched id-triples to terms, with one coalesced dictionary
5909    /// fault for exactly their terms.
5910    fn resolve_matches(&self, ids: Vec<Triple>) -> Vec<TermTriple> {
5911        // One coalesced dictionary fault for THIS pattern's output, not the
5912        // whole dictionary. `prefetch_all` used to sit here, which made a
5913        // bounded `(s, p, o, g)` lookup — the primitive an RDF4J `Sail`'s
5914        // `getStatements` calls, once per named graph via `query_quads` — pay
5915        // for every term in the file: on `cordis.rete` (801 MB, a 417 MB
5916        // dictionary) a 15-row answer read 416 MB and peaked at 2.5 GB RSS.
5917        // Same call `dump_batch` makes, for the same reason.
5918        if !ids.is_empty() {
5919            let mut nodes = Vec::with_capacity(ids.len() * 2);
5920            let mut preds = Vec::with_capacity(ids.len());
5921            for &(s, p, o) in &ids {
5922                nodes.push(self.dict.subject_node(s));
5923                nodes.push(self.dict.object_node(o));
5924                preds.push(p);
5925            }
5926            self.dict.prefetch_terms(&nodes, &preds);
5927        }
5928        ids.into_iter()
5929            .filter_map(|(s, p, o)| {
5930                Some((
5931                    self.dict.subject_term(s)?,
5932                    self.dict.predicate_term(p)?,
5933                    self.dict.object_term(o)?,
5934                ))
5935            })
5936            .collect()
5937    }
5938
5939    /// Match a triple pattern across the default graph **and every named graph**,
5940    /// tagging each match with its graph (`None` = default). The quad-level
5941    /// companion to [`Rete::query`]; default-graph matches come first, then each
5942    /// named graph in stored order.
5943    pub fn query_quads(
5944        &self,
5945        s: Option<&str>,
5946        p: Option<&str>,
5947        o: Option<&str>,
5948    ) -> Vec<(TermTriple, Option<String>)> {
5949        let mut out: Vec<(TermTriple, Option<String>)> = self
5950            .query_in_graph(None, s, p, o)
5951            .into_iter()
5952            .map(|t| (t, None))
5953            .collect();
5954        let Some(pattern) = self.resolve_query_pattern(s, p, o) else {
5955            return out;
5956        };
5957        for i in 0..self.named_graphs.count() {
5958            // A quad dump visits every graph by construction — exhaustive
5959            // demand, so a lazy walk may read the section in bulk chunks.
5960            let Some(iri) = self.named_graphs.name_at(&self.dict, i, true) else {
5961                continue;
5962            };
5963            // A small quad-index graph through an owned view, dropped here:
5964            // the walk holds one view at a time, not one per graph.
5965            let owned = self.named_graph_transient(i);
5966            let index = match &owned {
5967                Some(view) => view,
5968                None => match self.named_graphs.graph_at(&self.dict, i, true) {
5969                    Some((_, g)) => g,
5970                    None => continue,
5971                },
5972            };
5973            for triple in self.resolve_matches(index.match_pattern(pattern)) {
5974                out.push((triple, Some(iri.to_string())));
5975            }
5976        }
5977        out
5978    }
5979
5980    /// Evaluate one triple pattern through a [`RangeReader`] by fetching only
5981    /// the header, the dictionary, and — for a tiled (v0.2) file — the
5982    /// selected permutation section's tile **directory** plus the tile(s) the
5983    /// bound leading id routes to; an unbound leading id fetches the section's
5984    /// tile body in one request. v0.1 files fetch the whole selected section.
5985    /// Unknown bound terms return an empty result before touching the index.
5986    pub fn query_ranged<R: RangeReader>(
5987        reader: &R,
5988        s: Option<&str>,
5989        p: Option<&str>,
5990        o: Option<&str>,
5991    ) -> Result<Vec<TermTriple>, FileError> {
5992        let head = reader.read_at(0, (HEADER_LEN as u64).min(reader.len()))?;
5993        if crate::v06::open::is_v06(&head) {
5994            return Ok(v06_query_ranged(reader, head, s, p, o, true)?.unwrap_or_default());
5995        }
5996        let routed = match route_pattern(reader, head, s, p, o)? {
5997            Some(routed) => routed,
5998            None => return Ok(Vec::new()),
5999        };
6000        let matches = fetch_routed_matches(reader, &routed)?;
6001        Ok(matches
6002            .into_iter()
6003            .filter_map(|(s, p, o)| {
6004                Some((
6005                    routed.dict.subject_term(s)?,
6006                    routed.dict.predicate_term(p)?,
6007                    routed.dict.object_term(o)?,
6008                ))
6009            })
6010            .collect())
6011    }
6012
6013    /// Route one triple pattern to its permutation section without fetching
6014    /// any payload bytes. Returns `false` when a bound term is unknown and the
6015    /// index was skipped.
6016    pub fn route_pattern_ranged<R: RangeReader>(
6017        reader: &R,
6018        s: Option<&str>,
6019        p: Option<&str>,
6020        o: Option<&str>,
6021    ) -> Result<bool, FileError> {
6022        let head = reader.read_at(0, (HEADER_LEN as u64).min(reader.len()))?;
6023        if crate::v06::open::is_v06(&head) {
6024            return Ok(v06_query_ranged(reader, head, s, p, o, false)?.is_some());
6025        }
6026        Ok(route_pattern(reader, head, s, p, o)?.is_some())
6027    }
6028}
6029
6030/// [`Rete::query_ranged`] / [`Rete::route_pattern_ranged`] on a `0x06` file
6031/// through a borrowed reader: the front and the dictionary data — contiguous,
6032/// what the `0x05` path reads as header + dictionary — in one more request
6033/// after `head`, the pattern resolved there, then (with `fetch`) the routed
6034/// tiles in one span. `None` when a bound term is not in the dictionary.
6035fn v06_query_ranged<R: RangeReader>(
6036    reader: &R,
6037    head: Vec<u8>,
6038    s: Option<&str>,
6039    p: Option<&str>,
6040    o: Option<&str>,
6041    fetch: bool,
6042) -> Result<Option<Vec<TermTriple>>, FileError> {
6043    use crate::v06::{kind, open::SparseReader, Header06, Root06, HEADER_LEN};
6044    let len = reader.len();
6045    let h = Header06::from_bytes(&head)?;
6046    let root_end = (HEADER_LEN as u64 + h.root_len as u64).min(len);
6047    let root_bytes = if root_end <= head.len() as u64 {
6048        head[HEADER_LEN..root_end as usize].to_vec()
6049    } else {
6050        reader.read_at(HEADER_LEN as u64, root_end - HEADER_LEN as u64)?
6051    };
6052    let root = Root06::decode(&root_bytes, h.internal_codec)?;
6053    let dict = root.section(kind::DICT_DATA).and_then(|d| d.abs(&h).ok());
6054    // The open below reads only the 1 KiB it must (not the 64 KiB first
6055    // read): what it needs past that is the front and the dictionary, here.
6056    let end = dict
6057        .map_or(h.data_offset, |d| d.offset + d.len)
6058        .max(crate::header::HEADER_LEN as u64)
6059        .min(len);
6060    let mut held = head;
6061    if end > held.len() as u64 {
6062        let at = held.len() as u64;
6063        held.extend_from_slice(&reader.read_at(at, end - at)?);
6064    }
6065    let sparse = SparseReader::fetch(reader, held, &[])?;
6066    let rete = Rete::open_ranged_lazy_with(sparse, 0)?;
6067    let Some(pattern) = resolve_query_pattern(rete.dictionary(), s, p, o) else {
6068        return Ok(None);
6069    };
6070    if !fetch {
6071        return Ok(Some(Vec::new()));
6072    }
6073    let perm = GraphIndex::best_permutation_in(h.perms(), pattern);
6074    let si = perm.section_index();
6075    let index = rete.default_index();
6076    let list = index.tile_sections()[si];
6077    let n = list.len();
6078    let [pa, _, _] = perm.order_pattern(pattern);
6079    let (start, stop) = match pa {
6080        Some(a) => {
6081            let start = list.first_max_ge(a);
6082            let mut stop = start;
6083            while stop < n {
6084                list.ensure_span(stop, stop + 1);
6085                if list[stop].leading_range().0 > a {
6086                    break;
6087                }
6088                stop += 1;
6089            }
6090            (start, stop)
6091        }
6092        None => {
6093            list.ensure_span(0, n);
6094            (0, n)
6095        }
6096    };
6097    let ranges: Vec<(u64, u64)> = (start..stop)
6098        .filter_map(|i| index.tile_range(si, i))
6099        .collect();
6100    let mut out = Vec::new();
6101    if let (Some(&(lo, _)), Some(&(last, last_len))) = (ranges.first(), ranges.last()) {
6102        // The routed tiles are one contiguous run of the section: one request.
6103        let body = reader.read_at(lo, last + last_len - lo)?;
6104        for &(off, l) in &ranges {
6105            let at = (off - lo) as usize;
6106            let tile = decompress(h.block_codec, &body[at..at + l as usize])?;
6107            out.extend(GraphIndex::match_serialized_block(&tile, perm, pattern));
6108        }
6109    }
6110    out.sort_unstable();
6111    let d = rete.dictionary();
6112    Ok(Some(
6113        out.into_iter()
6114            .filter_map(|(s, p, o)| {
6115                Some((d.subject_term(s)?, d.predicate_term(p)?, d.object_term(o)?))
6116            })
6117            .collect(),
6118    ))
6119}
6120
6121/// A pattern routed to its permutation section: everything needed to fetch
6122/// matches, with no payload bytes read yet.
6123struct RoutedPattern {
6124    dict: Dictionary,
6125    pattern: Pattern,
6126    permutation: IndexPermutation,
6127    header: Header,
6128    /// Absolute byte range of the selected section's payload.
6129    section: ByteRange,
6130}
6131
6132/// Resolve a pattern against the remote dictionary and locate its permutation
6133/// section (header + dictionary + container framing only).
6134fn route_pattern<R: RangeReader>(
6135    reader: &R,
6136    head: Vec<u8>,
6137    s: Option<&str>,
6138    p: Option<&str>,
6139    o: Option<&str>,
6140) -> Result<Option<RoutedPattern>, FileError> {
6141    let header = Header::from_bytes(&head)?;
6142
6143    let dict_bytes = reader.read_at(header.dictionary_offset, header.dictionary_len)?;
6144    let dict = decode_dictionary_container(&dict_bytes, header.dict_codec)?;
6145
6146    let Some(pattern) = resolve_query_pattern(&dict, s, p, o) else {
6147        return Ok(None);
6148    };
6149    let permutation = GraphIndex::best_permutation_in(header.perms, pattern);
6150    let section = locate_container_section_ranged(
6151        reader,
6152        header.root_dir_offset,
6153        header.root_dir_len,
6154        header
6155            .perms
6156            .position(permutation)
6157            .ok_or(FileError::Container("routed to an absent permutation"))?,
6158        header.index_container_sections() as u64,
6159    )?;
6160    Ok(Some(RoutedPattern {
6161        dict,
6162        pattern,
6163        permutation,
6164        header,
6165        section,
6166    }))
6167}
6168
6169/// Fetch and scan a routed pattern's matches: read the tile directory, then only
6170/// the matching tile byte ranges (the run of covering tiles for a bound leading
6171/// id — one for ordinary groups, several for a split mega-group — the
6172/// O(matching bytes) promise).
6173fn fetch_routed_matches<R: RangeReader>(
6174    reader: &R,
6175    routed: &RoutedPattern,
6176) -> Result<Vec<Triple>, FileError> {
6177    let dir = read_tile_directory_ranged(reader, routed.section)?;
6178    let [pa, _, _] = routed.permutation.order_pattern(routed.pattern);
6179    let codec = routed.header.block_codec;
6180    let mut out = Vec::new();
6181    match pa {
6182        // Bound leading id: the run of covering tiles (several for a split
6183        // mega-group; one otherwise).
6184        Some(a) => {
6185            for e in dir.iter().filter(|e| e.min_a <= a && a <= e.max_a) {
6186                let len = e.end - e.start;
6187                let bytes = reader.read_at(checked_end(routed.section.offset, e.start)?, len)?;
6188                if bytes.len() as u64 != len {
6189                    return Err(FileError::Container("short tile read"));
6190                }
6191                let tile = decompress(codec, &bytes)?;
6192                out.extend(GraphIndex::match_serialized_block(
6193                    &tile,
6194                    routed.permutation,
6195                    routed.pattern,
6196                ));
6197            }
6198        }
6199        // Unbound leading id: every tile matters — fetch the contiguous tile
6200        // body in one request and slice it.
6201        None => {
6202            if let (Some(first), Some(last)) = (dir.first(), dir.last()) {
6203                let base = first.start;
6204                let body =
6205                    reader.read_at(checked_end(routed.section.offset, base)?, last.end - base)?;
6206                for e in &dir {
6207                    // Checked: a reader may return fewer bytes than asked.
6208                    let stored = checked_slice(&body, e.start - base, e.end - e.start)
6209                        .ok_or(FileError::Container("short tile read"))?;
6210                    let tile = decompress(codec, stored)?;
6211                    out.extend(GraphIndex::match_serialized_block(
6212                        &tile,
6213                        routed.permutation,
6214                        routed.pattern,
6215                    ));
6216                }
6217            }
6218        }
6219    }
6220    out.sort_unstable();
6221    Ok(out)
6222}
6223
6224fn resolve_query_pattern(
6225    dict: &Dictionary,
6226    s: Option<&str>,
6227    p: Option<&str>,
6228    o: Option<&str>,
6229) -> Option<Pattern> {
6230    let sid = match s {
6231        Some(t) => Some(dict.subject_id(t)?),
6232        None => None,
6233    };
6234    let pid = match p {
6235        Some(t) => Some(dict.predicate_id(t)?),
6236        None => None,
6237    };
6238    let oid = match o {
6239        Some(t) => Some(dict.object_id(t)?),
6240        None => None,
6241    };
6242    Some((sid, pid, oid))
6243}
6244
6245/// The lazily-chunked dictionary of a ranged open: per section, fetch the
6246/// header + chunk directory and fault the chunk bodies in on first term lookup.
6247///
6248/// `want` selects which of the four sections (0 shared, 1 subject-only,
6249/// 2 object-only, 3 predicates) are really read; a skipped one becomes an empty
6250/// section whose terms resolve to `None`. On a file written before #198 a
6251/// **directory is not small**: it carried each chunk's first term verbatim, so
6252/// the object-only section of a dataset that stores abstracts can run to
6253/// hundreds of megabytes, and fetching it is most of what a remote open costs.
6254/// Current builds key it by the shortest separator instead (a few bytes per
6255/// chunk), but that is write-side, so every already-published file still pays.
6256/// A reader that only ever resolves subjects (see [`SearchView`]) skips
6257/// sections 2 and 3 and pays none of it. [`Rete::open_ranged_lazy`] wants all
6258/// four.
6259fn ranged_chunked_dictionary<R: RangeReader + Send + Sync + 'static>(
6260    reader: &std::sync::Arc<R>,
6261    header: &Header,
6262    want: [bool; 4],
6263) -> Result<Dictionary, FileError> {
6264    // One (unlimited) cache shared across the four sections, keyed by
6265    // (section_index, chunk_index).
6266    let cache = crate::chunk_cache::ChunkCache::unlimited_arc();
6267    let mut dict_sections: Vec<crate::dict::ChunkedSection> = Vec::with_capacity(4);
6268    let mut section_ranges: [std::sync::Arc<Vec<ByteRange>>; 4] = Default::default();
6269    for si in 0..4 {
6270        if !want[si] {
6271            dict_sections.push(crate::dict::ChunkedSection::from_parts(
6272                crate::dict::SectionMeta {
6273                    term_count: 0,
6274                    restart_interval: 1,
6275                    restart_offsets: Vec::new(),
6276                },
6277                Vec::new(),
6278                None,
6279                cache.clone(),
6280                si as u8,
6281            ));
6282            continue;
6283        }
6284        let section = locate_container_section_ranged(
6285            reader.as_ref(),
6286            header.dictionary_offset,
6287            header.dictionary_len,
6288            si,
6289            4,
6290        )?;
6291        let (meta, entries) = read_dict_dir_ranged(reader.as_ref(), section)?;
6292        // One copy of the chunk ranges, shared by this section's loaders and
6293        // the dictionary's cross-section batch loader.
6294        let ranges: std::sync::Arc<Vec<ByteRange>> = std::sync::Arc::new(
6295            entries
6296                .iter()
6297                .map(|e| ByteRange {
6298                    offset: section.offset + e.start,
6299                    len: (e.end - e.start),
6300                })
6301                .collect(),
6302        );
6303        section_ranges[si] = ranges.clone();
6304        let chunks: Vec<crate::dict::SectionChunk> = entries
6305            .into_iter()
6306            .map(|e| crate::dict::SectionChunk::new(e.first_run, e.key, e.body_start))
6307            .collect();
6308        let chunk_reader = reader.clone();
6309        let codec = header.dict_codec;
6310        let loader_ranges = ranges.clone();
6311        let loader: crate::dict::ChunkLoader = Box::new(move |ci| {
6312            let range = loader_ranges.get(ci)?;
6313            let bytes = chunk_reader.read_at(range.offset, range.len).ok()?;
6314            decompress(codec, &bytes).ok()
6315        });
6316        // Full-section sweeps (export/dump) batch their chunk fetches:
6317        // adjacent chunk ranges coalesce into a handful of range reads.
6318        let bulk_reader = reader.clone();
6319        let bulk: crate::dict::ChunkBulkLoader = Box::new(move |cis| {
6320            let want: Option<Vec<ByteRange>> =
6321                cis.iter().map(|&ci| ranges.get(ci).copied()).collect();
6322            let blobs = read_coalesced(bulk_reader.as_ref(), &want?, DICT_COALESCE_GAP)?;
6323            blobs.iter().map(|b| decompress(codec, b).ok()).collect()
6324        });
6325        dict_sections.push(
6326            crate::dict::ChunkedSection::from_parts(
6327                meta,
6328                chunks,
6329                Some(loader),
6330                cache.clone(),
6331                si as u8,
6332            )
6333            .with_bulk_loader(bulk),
6334        );
6335    }
6336    let dict_arr: [crate::dict::ChunkedSection; 4] = dict_sections
6337        .try_into()
6338        .map_err(|_| FileError::Container("expected 4 dictionary sections"))?;
6339    // Decoding a result touches several sections at once; this fetches all
6340    // their missing chunks in one batch (pairs arrive section by section in
6341    // ascending chunk order, so ascending in the file, as coalescing wants).
6342    let multi_reader = reader.clone();
6343    let codec = header.dict_codec;
6344    let multi: crate::dict::MultiChunkBulkLoader = Box::new(move |pairs| {
6345        let want: Option<Vec<ByteRange>> = pairs
6346            .iter()
6347            .map(|&(si, ci)| section_ranges.get(si as usize)?.get(ci).copied())
6348            .collect();
6349        let blobs = read_coalesced(multi_reader.as_ref(), &want?, DICT_COALESCE_GAP)?;
6350        blobs.iter().map(|b| decompress(codec, b).ok()).collect()
6351    });
6352    Ok(Dictionary::from_chunked_sections(dict_arr).with_multi_bulk_loader(multi))
6353}
6354
6355/// Run two independent steps of an open: at the same time when the reader can
6356/// keep several reads in flight (`concurrency > 1`) on a target with threads,
6357/// else one after the other. Either way both run to completion and their
6358/// results come back as they would inline; a panic in either propagates.
6359#[cfg(not(target_arch = "wasm32"))]
6360fn open_side_by_side<A, B: Send>(
6361    concurrency: usize,
6362    a: impl FnOnce() -> A,
6363    b: impl FnOnce() -> B + Send,
6364) -> (A, B) {
6365    if concurrency <= 1 {
6366        return (a(), b());
6367    }
6368    std::thread::scope(|scope| {
6369        let hb = scope.spawn(b);
6370        let ra = a();
6371        let rb = hb
6372            .join()
6373            .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
6374        (ra, rb)
6375    })
6376}
6377
6378/// wasm32 has no threads here: always one after the other.
6379#[cfg(target_arch = "wasm32")]
6380fn open_side_by_side<A, B>(
6381    _concurrency: usize,
6382    a: impl FnOnce() -> A,
6383    b: impl FnOnce() -> B,
6384) -> (A, B) {
6385    (a(), b())
6386}
6387
6388/// The `v06-parity` test hook: a 0x05 image converted to 0x06 (`None` for a
6389/// 0x06 image, or one the converter refuses, such as per-graph named graphs).
6390#[cfg(feature = "v06-parity")]
6391fn parity_image(bytes: &[u8]) -> Option<Vec<u8>> {
6392    if crate::v06::open::is_v06(bytes) || bytes.len() < HEADER_LEN {
6393        return None;
6394    }
6395    crate::v06::write::convert_image(bytes, crate::v06::write::WriteOpts::default()).ok()
6396}
6397
6398/// [`Rete::file_layout`] for a `0x06` file: the header, the root, the card
6399/// region, then every front and data section, in file order.
6400fn v06_layout(f: &crate::v06::open::V06File) -> Vec<LayoutSegment> {
6401    let h = &f.h;
6402    let seg = |kind: &'static str, label: String, offset: u64, len: u64| LayoutSegment {
6403        kind,
6404        label,
6405        offset,
6406        len,
6407    };
6408    let mut out = vec![
6409        seg(
6410            "header",
6411            format!("header (fixed {} bytes)", crate::v06::HEADER_LEN),
6412            0,
6413            crate::v06::HEADER_LEN as u64,
6414        ),
6415        seg(
6416            "directory",
6417            "root block (section table, directory roots)".into(),
6418            h.root_offset(),
6419            h.root_len as u64,
6420        ),
6421    ];
6422    let c = h.card_range();
6423    if c.len > 0 {
6424        out.push(seg(
6425            "metadata",
6426            "metadata (dataset card, zstd)".into(),
6427            c.offset,
6428            c.len,
6429        ));
6430    }
6431    let b = h.build_range();
6432    if b.len > 0 {
6433        out.push(seg("build-info", "build record".into(), b.offset, b.len));
6434    }
6435    for s in &f.root.sections {
6436        let Ok(a) = s.abs(h) else { continue };
6437        let kind: &'static str = match s.kind {
6438            crate::v06::kind::SCHEMA => "schema",
6439            crate::v06::kind::DICT_DATA => "dictionary",
6440            crate::v06::kind::INDEX_DATA => "tile",
6441            crate::v06::kind::QUAD_DATA => "named-graphs",
6442            crate::v06::kind::LABEL_DATA => "label-index",
6443            crate::v06::kind::TEXT_DATA => "text-index",
6444            crate::v06::kind::PYRAMID => "pyramid",
6445            _ => "directory",
6446        };
6447        out.push(seg(
6448            kind,
6449            crate::v06::kind::name(s.kind).to_string(),
6450            a.offset,
6451            a.len,
6452        ));
6453    }
6454    out.sort_by_key(|s| s.offset);
6455    out
6456}
6457
6458/// The deferred slots of a `0x06` open: pyramid, text index, label index and
6459/// schema, each faulted on first use.
6460#[allow(clippy::type_complexity)]
6461fn v06_slots(
6462    f: &std::sync::Arc<crate::v06::open::V06File>,
6463) -> (
6464    PyramidSlot,
6465    TextIndexSlot,
6466    SectionSlot<crate::label_index::LabelIndex>,
6467    SectionSlot<crate::schema_section::SchemaMeta>,
6468) {
6469    use crate::v06::{P_LABELS, P_PYRAMID, P_SCHEMA, P_TEXT};
6470    let pyramid = if f.h.has(P_PYRAMID) {
6471        let a = f.clone();
6472        PyramidSlot::Lazy {
6473            loader: Box::new(move || PyramidMeta::decode(&a.pyramid_bytes()?).ok()),
6474            cell: std::sync::OnceLock::new(),
6475        }
6476    } else {
6477        PyramidSlot::Resident(None)
6478    };
6479    let text_index = if f.h.has(P_TEXT) {
6480        let (a, b) = (f.clone(), f.clone());
6481        TextIndexSlot::Lazy {
6482            loader: Box::new(move || a.text_index()?.ok()),
6483            cell: std::sync::OnceLock::new(),
6484            token_table: Box::new(move || b.token_table_bytes()),
6485            token_table_cell: std::sync::OnceLock::new(),
6486        }
6487    } else {
6488        TextIndexSlot::Resident {
6489            index: None,
6490            token_table_len: None,
6491        }
6492    };
6493    let labels = if f.h.has(P_LABELS) {
6494        let a = f.clone();
6495        SectionSlot::Lazy {
6496            loader: Box::new(move || a.label_index()?.ok()),
6497            cell: std::sync::OnceLock::new(),
6498        }
6499    } else {
6500        SectionSlot::Resident(None)
6501    };
6502    let schema = if f.h.has(P_SCHEMA) {
6503        let a = f.clone();
6504        SectionSlot::Lazy {
6505            loader: Box::new(move || a.schema()),
6506            cell: std::sync::OnceLock::new(),
6507        }
6508    } else {
6509        SectionSlot::Resident(None)
6510    };
6511    (pyramid, text_index, labels, schema)
6512}
6513
6514/// The pyramid meta is large on real graphs (tens of MB) and SPARQL never reads
6515/// it, so a ranged open defers its fetch: it faults in only if `pyramid()` is
6516/// called (community / pyramid_tree / label-prefix queries).
6517fn ranged_pyramid_slot<R: RangeReader + Send + Sync + 'static>(
6518    reader: &std::sync::Arc<R>,
6519    header: &Header,
6520) -> PyramidSlot {
6521    if header.pyramid_meta_len == 0 {
6522        return PyramidSlot::Resident(None);
6523    }
6524    let pyr_reader = reader.clone();
6525    let pyr_off = header.pyramid_meta_offset;
6526    let pyr_len = header.pyramid_meta_len;
6527    PyramidSlot::Lazy {
6528        loader: Box::new(move || {
6529            let mb = pyr_reader.read_at(pyr_off, pyr_len).ok()?;
6530            PyramidMeta::decode(&mb).ok()
6531        }),
6532        cell: std::sync::OnceLock::new(),
6533    }
6534}
6535
6536/// The deferred TEXT_INDEX of a ranged open: a text search faults the token
6537/// table on first call (the leading varint then its compressed bytes), then
6538/// fetches individual posting lists by `(offset, len)` — never the whole
6539/// postings blob. A caller that never searches pays nothing.
6540fn ranged_text_index_slot<R: RangeReader + Send + Sync + 'static>(
6541    reader: &std::sync::Arc<R>,
6542    header: &Header,
6543) -> TextIndexSlot {
6544    let (ti_off, ti_len) = header.text_index_payload();
6545    if ti_len == 0 {
6546        return TextIndexSlot::Resident {
6547            index: None,
6548            token_table_len: None,
6549        };
6550    }
6551    let ti_reader = reader.clone();
6552    let probe_reader = reader.clone();
6553    let codec = header.block_codec;
6554    TextIndexSlot::Lazy {
6555        loader: Box::new(move || {
6556            // The section opens with `varint token_table_len`; read enough to
6557            // decode it (a uvarint is ≤ 10 bytes), then fetch the varint + the
6558            // compressed token table as one prefix range.
6559            let prefix_len = read_token_table_len(&*ti_reader, ti_off, ti_len)?;
6560            let prefix = ti_reader.read_at(ti_off, prefix_len).ok()?;
6561            let postings_base = crate::text_index::TextIndex::postings_base(&prefix)? as u64;
6562            let postings_abs = ti_off + postings_base;
6563            let pr = ti_reader.clone();
6564            let posting_loader =
6565                Box::new(move |off: u64, len: u64| pr.read_at(postings_abs + off, len).ok());
6566            crate::text_index::TextIndex::from_token_table(&prefix, codec, posting_loader).ok()
6567        }),
6568        cell: std::sync::OnceLock::new(),
6569        // The same leading varint the loader starts from, asked on its own so a
6570        // caller can *state* the cost of a first search without paying it.
6571        token_table: Box::new(move || read_token_table_len(&*probe_reader, ti_off, ti_len)),
6572        token_table_cell: std::sync::OnceLock::new(),
6573    }
6574}
6575
6576/// The `(label, subject term)` pairs of a legacy (pyramid-block) label search.
6577/// The hits' subjects are scattered over the dictionary, so their chunks are
6578/// faulted in one batch first (one round trip on a remote file, as
6579/// [`text_search_in`] does for full-text hits), then decoded in one
6580/// chunk-ordered pass. A subject that does not resolve drops its hit, as
6581/// before; the order is the search's.
6582fn resolve_label_hits(
6583    dict: &Dictionary,
6584    hits: Vec<&crate::meta::LabelEntry>,
6585) -> Vec<(String, String)> {
6586    let ids: Vec<u32> = hits.iter().map(|e| e.subject).collect();
6587    dict.prefetch_subject_terms(&ids);
6588    hits.iter()
6589        .zip(dict.subject_terms(&ids))
6590        .filter_map(|(e, iri)| Some((e.label.clone(), iri?)))
6591        .collect()
6592}
6593
6594/// Full-text search over `ti`, resolving the matched subject ids through `dict`.
6595/// The engine behind [`Rete::text_search`] and [`SearchView::text_search`].
6596fn text_search_in(
6597    ti: Option<&crate::text_index::TextIndex>,
6598    dict: &Dictionary,
6599    words: &[&str],
6600    prefix: Option<&str>,
6601    limit: usize,
6602) -> Vec<String> {
6603    let Some(ti) = ti else {
6604        return Vec::new();
6605    };
6606    // Each query word is tokenized exactly as at build time (so "Glucose"
6607    // matches the stored "glucose"); a word that splits into several tokens
6608    // requires all of them. AND across every required token + the prefix.
6609    let mut acc: Option<Vec<u32>> = None;
6610    if let Some(p) = prefix {
6611        acc = Some(ti.prefix(&p.to_lowercase()));
6612    }
6613    for w in words {
6614        for tok in crate::text_index::tokenize(w) {
6615            let posting = ti.lookup(&tok);
6616            acc = Some(match acc {
6617                Some(a) => intersect_sorted(&a, &posting),
6618                None => posting,
6619            });
6620            if acc.as_ref().is_some_and(|a| a.is_empty()) {
6621                return Vec::new();
6622            }
6623        }
6624    }
6625    let ids = acc.unwrap_or_default();
6626    let mut out = Vec::with_capacity(if limit > 0 {
6627        limit.min(ids.len())
6628    } else {
6629        ids.len()
6630    });
6631    // The hits' subject IRIs are scattered over many dictionary chunks: fault
6632    // the ones a page can return in one batch (on a remote file, one round
6633    // trip instead of one per chunk). Only warms the cache.
6634    let page = if limit > 0 {
6635        limit.min(ids.len())
6636    } else {
6637        ids.len()
6638    };
6639    dict.prefetch_subject_terms(&ids[..page]);
6640    for id in ids {
6641        if let Some(iri) = dict.subject_term(id) {
6642            out.push(iri);
6643            if limit > 0 && out.len() >= limit {
6644                break;
6645            }
6646        }
6647    }
6648    out
6649}
6650
6651/// A `.rete` opened for **search only**: the dictionary's chunk directories, and
6652/// the TEXT_INDEX / pyramid deferred behind their lazy slots. Nothing else.
6653///
6654/// What makes this cheap is skipping dictionary sections 2 and 3 (object-only
6655/// and predicates) — NOT skipping the index. Measured on the published
6656/// `epfl-infoscience.rete` (1.64 GB), a full [`Rete::open_ranged_lazy`] reads
6657/// 536,947,344 B, of which the six permutation tile directories are **49,940 B
6658/// in 40 reads** and the dictionary is 536,896,380 B in 36 reads: the
6659/// object-only chunk directory alone is 234,400,728 B, because that file stores
6660/// every chunk's first term verbatim. Files built since #198 key it by the
6661/// shortest separator (~600 KB on this graph); the published one still does
6662/// not, and only a rebuild changes that. This view opens sections 0 and 1
6663/// only — both search modes return subject IRIs — and costs **21,554 B in 9
6664/// reads** on the same file. It also skips the index container and defers the
6665/// TEXT_INDEX and pyramid, so it pays only for what it reads: the token table
6666/// on the first search, then one range per posting list and per dictionary
6667/// chunk holding a matched subject. This is what backs `rete search-url`.
6668///
6669/// On a file with a LABEL_INDEX section (kind 8) the dictionary is not opened
6670/// at all until a full-text search needs it: the section stores each subject's
6671/// term, so a prefix search costs the header, the section head, one directory
6672/// node per level and one data block.
6673pub struct SearchView {
6674    header: Header,
6675    dict: SectionSlot<Dictionary>,
6676    pyramid: PyramidSlot,
6677    text_index: TextIndexSlot,
6678    labels: SectionSlot<crate::label_index::LabelIndex>,
6679}
6680
6681impl SearchView {
6682    /// Open for search over any [`RangeReader`] (HTTP or local): header +
6683    /// dictionary chunk directories only (none at all on a file with a
6684    /// LABEL_INDEX, until a text search needs them), with the text index,
6685    /// label index and pyramid faulting in on first use.
6686    pub fn open_ranged<R: RangeReader + Send + Sync + 'static>(
6687        reader: R,
6688    ) -> Result<Self, FileError> {
6689        Self::open_ranged_with(reader, crate::v06::FIRST_READ)
6690    }
6691
6692    /// [`open_ranged`](Self::open_ranged) with an explicit first-read size
6693    /// (see [`Rete::open_ranged_lazy_with`]).
6694    pub fn open_ranged_with<R: RangeReader + Send + Sync + 'static>(
6695        reader: R,
6696        first_read: u64,
6697    ) -> Result<Self, FileError> {
6698        let first_len = first_read.max(HEADER_LEN as u64).min(reader.len());
6699        let first: std::sync::Arc<[u8]> = std::sync::Arc::from(reader.read_at(0, first_len)?);
6700        let reader = crate::reader::PrefixReader::new(reader, first.clone());
6701        if crate::v06::open::is_v06(&first) {
6702            let r: std::sync::Arc<dyn RangeReader + Send + Sync> = std::sync::Arc::new(reader);
6703            let f = std::sync::Arc::new(crate::v06::open::V06File::open(r, &first)?);
6704            let header = crate::v06::logical_header(&f.h);
6705            let (pyramid, text_index, labels, _) = v06_slots(&f);
6706            let want = [true, true, false, false];
6707            let dict = if f.h.has(crate::v06::P_LABELS) {
6708                let a = f.clone();
6709                SectionSlot::Lazy {
6710                    loader: Box::new(move || a.dictionary(want).ok()),
6711                    cell: std::sync::OnceLock::new(),
6712                }
6713            } else {
6714                SectionSlot::Resident(Some(f.dictionary(want)?))
6715            };
6716            return Ok(Self {
6717                header,
6718                dict,
6719                pyramid,
6720                text_index,
6721                labels,
6722            });
6723        }
6724        let head = reader.read_at(0, HEADER_LEN as u64)?;
6725        let header = Header::from_bytes(&head)?;
6726        let reader = std::sync::Arc::new(reader);
6727        // Sections 0 (shared) and 1 (subject-only) are the only ones a search
6728        // resolves — both modes return subject IRIs. Skipping 2 and 3 is what
6729        // makes this open cheap where a full one is not.
6730        let dict = if header.label_index_section().is_some() {
6731            let (r, h) = (reader.clone(), header.clone());
6732            SectionSlot::Lazy {
6733                loader: Box::new(move || {
6734                    ranged_chunked_dictionary(&r, &h, [true, true, false, false]).ok()
6735                }),
6736                cell: std::sync::OnceLock::new(),
6737            }
6738        } else {
6739            SectionSlot::Resident(Some(ranged_chunked_dictionary(
6740                &reader,
6741                &header,
6742                [true, true, false, false],
6743            )?))
6744        };
6745        let pyramid = ranged_pyramid_slot(&reader, &header);
6746        let text_index = ranged_text_index_slot(&reader, &header);
6747        let labels = ranged_label_slot(&reader, &header);
6748        Ok(Self {
6749            header,
6750            dict,
6751            pyramid,
6752            text_index,
6753            labels,
6754        })
6755    }
6756
6757    /// Whether the file can answer a prefix search: it carries a LABEL_INDEX
6758    /// section or a pyramid (whose meta may hold the legacy label block). A
6759    /// header read, no fault.
6760    pub fn has_labels(&self) -> bool {
6761        self.header.label_index_section().is_some() || self.header.pyramid_meta_len > 0
6762    }
6763
6764    /// Whether the file carries a LABEL_INDEX section (kind 8).
6765    pub fn has_label_index(&self) -> bool {
6766        self.header.label_index_section().is_some()
6767    }
6768
6769    /// The file header (already fetched by [`open_ranged`](Self::open_ranged)).
6770    pub fn header(&self) -> &Header {
6771        &self.header
6772    }
6773
6774    /// Whether the file carries a TEXT_INDEX section — a header read, no fault.
6775    pub fn has_text_index(&self) -> bool {
6776        self.header.text_index_len > 0
6777    }
6778
6779    /// Whether the file carries a pyramid (where the label index lives) — a
6780    /// header read, no fault.
6781    pub fn has_pyramid(&self) -> bool {
6782        self.header.pyramid_meta_len > 0
6783    }
6784
6785    /// Full-text search — see [`Rete::text_search`] for the semantics. Faults
6786    /// the token table on the first call, then only the queried postings.
6787    pub fn text_search(&self, words: &[&str], prefix: Option<&str>, limit: usize) -> Vec<String> {
6788        let Some(dict) = self.dict.get() else {
6789            return Vec::new();
6790        };
6791        text_search_in(self.text_index.index(), dict, words, prefix, limit)
6792    }
6793
6794    /// What that first search will cost — see
6795    /// [`Rete::text_index_token_table_len`]. One ≤10-byte range read, so a
6796    /// caller can quote the figure before deciding to search.
6797    pub fn text_index_token_table_len(&self) -> Option<u64> {
6798        self.text_index.token_table_len()
6799    }
6800
6801    /// Label-prefix search — see [`Rete::prefix_search`]. Reads the
6802    /// LABEL_INDEX section when the file has one; otherwise faults the pyramid
6803    /// (which carries the legacy label block), the pricier path.
6804    pub fn prefix_search(&self, prefix: &str, limit: usize) -> Vec<(String, String)> {
6805        if let Some(li) = self.labels.get() {
6806            return li.prefix_search(prefix, limit);
6807        }
6808        let pyr = match &self.pyramid {
6809            PyramidSlot::Resident(p) => p.as_ref(),
6810            PyramidSlot::Lazy { loader, cell } => cell.get_or_init(loader).as_ref(),
6811        };
6812        let (Some(pyr), Some(dict)) = (pyr, self.dict.get()) else {
6813            return Vec::new();
6814        };
6815        resolve_label_hits(dict, pyr.prefix_search(prefix, limit))
6816    }
6817}
6818
6819/// A lightweight, overview-only view of a file: the pyramid summary graph plus
6820/// just enough dictionary to label predicates. Fetched via ranges *without*
6821/// touching the (large) triple index — the "load the coarse graph first" path
6822/// from SPEC.md §7.2.
6823#[must_use]
6824pub struct SummaryView {
6825    pub round: u32,
6826    pub summary: Vec<SuperEdge>,
6827    /// The shipped `subClassOf` hierarchy (v2 schema pyramid; empty on v1 files).
6828    pub class_hierarchy: Vec<ClassNode>,
6829    /// Per-level type rollups — the leveled legend, read index-free.
6830    pub level_rollups: Vec<LevelRollup>,
6831    /// Per-level lateral class-relation graph (the non-`is-a` connections).
6832    pub level_links: Vec<LevelLinks>,
6833    /// Per-community descriptors (Phase 4 progressive refinement; may be empty).
6834    pub descriptors: Vec<CommunityDescriptor>,
6835    /// `subClassOf` cycles (v2.1; empty on older files).
6836    pub subclass_cycles: Vec<Vec<String>>,
6837    /// `owl:disjointWith` class pairs (v2.1; empty on older files).
6838    pub disjoint_pairs: Vec<(String, String)>,
6839    /// `owl:equivalentClass` class pairs (v2.1; empty on older files).
6840    pub equivalent_pairs: Vec<(String, String)>,
6841    dict: Dictionary,
6842}
6843
6844impl SummaryView {
6845    /// Read header → dictionary → pyramid-meta only (skips the index container).
6846    pub fn open_ranged<R: RangeReader>(reader: &R) -> Result<Option<Self>, FileError> {
6847        let head = reader.read_at(0, (HEADER_LEN as u64).min(reader.len()))?;
6848        let header = Header::from_bytes(&head)?;
6849        if header.pyramid_meta_len == 0 {
6850            return Ok(None);
6851        }
6852        if header.version == crate::v06::VERSION {
6853            return Self::open_ranged_v06(reader, &header, head).map(Some);
6854        }
6855
6856        let dict_bytes = reader.read_at(header.dictionary_offset, header.dictionary_len)?;
6857        let dict = decode_dictionary_container(&dict_bytes, header.dict_codec)?;
6858
6859        let mb = reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?;
6860        let mut meta =
6861            PyramidMeta::decode(&mb).map_err(|_| FileError::Container("malformed pyramid meta"))?;
6862        // A file whose schema moved to its own section (kind 9) keeps only the
6863        // community material here: take the schema fields from that section.
6864        if let Some(s) = read_schema_with_header(reader, &header) {
6865            meta.class_hierarchy = s.class_hierarchy;
6866            meta.level_rollups = s.level_rollups;
6867            meta.level_links = s.level_links;
6868            meta.subclass_cycles = s.subclass_cycles;
6869            meta.disjoint_pairs = s.disjoint_pairs;
6870            meta.equivalent_pairs = s.equivalent_pairs;
6871        }
6872
6873        Ok(Some(SummaryView {
6874            round: meta.round,
6875            summary: meta.summary,
6876            class_hierarchy: meta.class_hierarchy,
6877            level_rollups: meta.level_rollups,
6878            level_links: meta.level_links,
6879            descriptors: meta.descriptors,
6880            subclass_cycles: meta.subclass_cycles,
6881            disjoint_pairs: meta.disjoint_pairs,
6882            equivalent_pairs: meta.equivalent_pairs,
6883            dict,
6884        }))
6885    }
6886
6887    /// [`open_ranged`](Self::open_ranged) for a `0x06` file: the pyramid
6888    /// section, the schema, and the dictionary (its leaves and chunks read
6889    /// whole, as the `0x05` path reads the whole dictionary container).
6890    fn open_ranged_v06<R: RangeReader>(
6891        reader: &R,
6892        header: &Header,
6893        head: Vec<u8>,
6894    ) -> Result<Self, FileError> {
6895        use crate::v06::{kind, open};
6896        // One first read, then every range the view needs past it — the
6897        // pyramid, the schema, the dictionary's leaves and chunks — in one
6898        // batch.
6899        let mut first = head;
6900        let want_first = crate::v06::FIRST_READ.min(reader.len());
6901        if want_first > first.len() as u64 {
6902            let at = first.len() as u64;
6903            first.extend_from_slice(&reader.read_at(at, want_first - at)?);
6904        }
6905        let pre = open::Prefixed {
6906            first: &first,
6907            r: reader,
6908        };
6909        let (h, root) = open::read_front(&pre)?;
6910        let want: Vec<ByteRange> = [
6911            kind::PYRAMID,
6912            kind::SCHEMA,
6913            kind::DICT_LEAVES,
6914            kind::DICT_DATA,
6915        ]
6916        .iter()
6917        .filter_map(|&k| root.section(k).and_then(|s| s.abs(&h).ok()))
6918        .collect();
6919        let sparse = open::SparseReader::fetch(&pre, first.clone(), &want)?;
6920        let mb = open::read_section(&sparse, &h, &root, kind::PYRAMID)?
6921            .ok_or(FileError::Container("0x06 file has no pyramid"))?;
6922        let mut meta =
6923            PyramidMeta::decode(&mb).map_err(|_| FileError::Container("malformed pyramid meta"))?;
6924        if let Some(s) = read_schema_with_header(&sparse, header) {
6925            meta.class_hierarchy = s.class_hierarchy;
6926            meta.level_rollups = s.level_rollups;
6927            meta.level_links = s.level_links;
6928            meta.subclass_cycles = s.subclass_cycles;
6929            meta.disjoint_pairs = s.disjoint_pairs;
6930            meta.equivalent_pairs = s.equivalent_pairs;
6931        }
6932        let f = open::V06File::open(std::sync::Arc::new(sparse), &first)?;
6933        let dict = f.dictionary([true; 4])?;
6934        Ok(SummaryView {
6935            round: meta.round,
6936            summary: meta.summary,
6937            class_hierarchy: meta.class_hierarchy,
6938            level_rollups: meta.level_rollups,
6939            level_links: meta.level_links,
6940            descriptors: meta.descriptors,
6941            subclass_cycles: meta.subclass_cycles,
6942            disjoint_pairs: meta.disjoint_pairs,
6943            equivalent_pairs: meta.equivalent_pairs,
6944            dict,
6945        })
6946    }
6947
6948    /// A schema-only view of a file **without a pyramid**, from its SCHEMA
6949    /// section (kind 9): the hierarchy, levels and axioms, with an empty
6950    /// community summary. Reads the header and the SCHEMA section only — the
6951    /// section carries its own class and predicate strings, so no dictionary.
6952    /// `Ok(None)` when the file has no SCHEMA section. Callers that need the
6953    /// summary (`cost`, federation source selection, progressive queries) use
6954    /// [`open_ranged`](Self::open_ranged), which keeps requiring a pyramid.
6955    pub fn open_schema_ranged<R: RangeReader>(reader: &R) -> Result<Option<Self>, FileError> {
6956        let head = reader.read_at(0, HEADER_LEN as u64)?;
6957        let header = Header::from_bytes(&head)?;
6958        let Some(s) = read_schema_with_header(reader, &header) else {
6959            return Ok(None);
6960        };
6961        let dict = crate::DictionaryBuilder::new().build();
6962        Ok(Some(SummaryView {
6963            round: 0,
6964            summary: Vec::new(),
6965            class_hierarchy: s.class_hierarchy,
6966            level_rollups: s.level_rollups,
6967            level_links: s.level_links,
6968            descriptors: Vec::new(),
6969            subclass_cycles: s.subclass_cycles,
6970            disjoint_pairs: s.disjoint_pairs,
6971            equivalent_pairs: s.equivalent_pairs,
6972            dict,
6973        }))
6974    }
6975
6976    /// Number of semantic-zoom levels in the schema pyramid (0 if none shipped).
6977    pub fn level_count(&self) -> usize {
6978        self.level_rollups.len()
6979    }
6980
6981    /// The type rollup at semantic level `k` (0 = coarsest/most abstract), or
6982    /// `None` if `k` is out of range. Index-free — answered from the pyramid-meta.
6983    pub fn level_rollup(&self, k: usize) -> Option<&LevelRollup> {
6984        self.level_rollups.get(k)
6985    }
6986
6987    /// Resolve a predicate ID in the summary to its term.
6988    pub fn predicate_term(&self, id: u32) -> Option<String> {
6989        self.dict.predicate_term(id)
6990    }
6991
6992    /// Exact number of triples using `predicate`, summed from the summary's
6993    /// superedge counts — answered without ever reading the triple index.
6994    pub fn predicate_total(&self, predicate: &str) -> u32 {
6995        match self.dict.predicate_id(predicate) {
6996            Some(pid) => self
6997                .summary
6998                .iter()
6999                .filter(|e| e.predicate == pid)
7000                .map(|e| e.count)
7001                .sum(),
7002            None => 0,
7003        }
7004    }
7005
7006    /// All predicates with their exact triple totals, descending by count.
7007    pub fn predicate_totals(&self) -> Vec<(String, u32)> {
7008        let mut by_pred: std::collections::BTreeMap<u32, u32> = std::collections::BTreeMap::new();
7009        for e in &self.summary {
7010            *by_pred.entry(e.predicate).or_default() += e.count;
7011        }
7012        let mut out: Vec<(String, u32)> = by_pred
7013            .into_iter()
7014            .filter_map(|(pid, c)| self.dict.predicate_term(pid).map(|t| (t, c)))
7015            .collect();
7016        out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
7017        out
7018    }
7019
7020    /// Number of communities the summary spans (distinct supernode endpoints).
7021    pub fn community_count(&self) -> usize {
7022        let mut comms = std::collections::BTreeSet::new();
7023        for e in &self.summary {
7024            comms.insert(e.s_comm);
7025            comms.insert(e.o_comm);
7026        }
7027        comms.len()
7028    }
7029
7030    /// **Index-free T-Box coherence (Tier-0).** Detect schema-level incoherent
7031    /// points purely from the shipped schema pyramid — no triple index, no
7032    /// instance data, O(ontology) regardless of graph size:
7033    /// - `subclass-cycle`: a set of classes that are mutually `rdfs:subClassOf`.
7034    /// - `unsatisfiable-class`: a class whose ancestor closure (over all parents,
7035    ///   folded through `owl:equivalentClass`) contains both ends of an
7036    ///   `owl:disjointWith` pair, so no individual can ever be one.
7037    ///
7038    /// Soundness is bounded by what the pyramid ships: the `subClassOf` hierarchy
7039    /// is capped (`MAX_HIERARCHY` in `schema_pyramid`), so on a very large ontology
7040    /// a pruned ancestor can hide an unsatisfiable class (a false *coherent*, never
7041    /// a false *incoherent*). Instance-level clashes (a node typed into disjoint
7042    /// classes, functional-property clashes) are NOT visible here — they need the
7043    /// A-Box (Tier-1/Tier-2 `reason`).
7044    pub fn tbox_coherence(&self) -> Vec<crate::reason::Inconsistency> {
7045        schema_coherence(
7046            &self.class_hierarchy,
7047            &self.subclass_cycles,
7048            &self.disjoint_pairs,
7049            &self.equivalent_pairs,
7050        )
7051    }
7052
7053    /// True when [`tbox_coherence`](Self::tbox_coherence) finds no schema-level
7054    /// incoherent point.
7055    pub fn tbox_is_coherent(&self) -> bool {
7056        self.tbox_coherence().is_empty()
7057    }
7058}
7059
7060/// Compute T-Box coherence points from the schema-pyramid fields alone — no
7061/// dictionary, no index, no instance data. Shared by [`SummaryView::tbox_coherence`]
7062/// and the dictionary-free [`read_schema_coherence_ranged`]. Emits `subclass-cycle`
7063/// and `unsatisfiable-class` (a class whose ancestor closure — over all parents,
7064/// folded through `owl:equivalentClass` — contains both ends of a disjoint pair).
7065pub fn schema_coherence(
7066    class_hierarchy: &[ClassNode],
7067    subclass_cycles: &[Vec<String>],
7068    disjoint_pairs: &[(String, String)],
7069    equivalent_pairs: &[(String, String)],
7070) -> Vec<crate::reason::Inconsistency> {
7071    use crate::reason::Inconsistency;
7072    use std::collections::{BTreeMap, BTreeSet, VecDeque};
7073    const MAX_REACH: usize = 100_000;
7074
7075    let mut out: Vec<Inconsistency> = Vec::new();
7076
7077    for cyc in subclass_cycles {
7078        let detail = if cyc.len() == 1 {
7079            format!("{} is rdfs:subClassOf itself (a cycle)", cyc[0])
7080        } else {
7081            format!(
7082                "classes {{{}}} are mutually rdfs:subClassOf (a cycle)",
7083                cyc.join(", ")
7084            )
7085        };
7086        out.push(Inconsistency {
7087            kind: "subclass-cycle",
7088            detail,
7089        });
7090    }
7091
7092    if !disjoint_pairs.is_empty() {
7093        // Upward adjacency: subClassOf parents + bidirectional equivalence.
7094        let mut adj: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
7095        for n in class_hierarchy {
7096            let e = adj.entry(n.class.as_str()).or_default();
7097            for p in &n.parents {
7098                e.push(p.as_str());
7099            }
7100        }
7101        for (a, b) in equivalent_pairs {
7102            adj.entry(a.as_str()).or_default().push(b.as_str());
7103            adj.entry(b.as_str()).or_default().push(a.as_str());
7104        }
7105
7106        // Candidate focus classes: every class named anywhere in the schema.
7107        let mut focuses: BTreeSet<&str> =
7108            class_hierarchy.iter().map(|n| n.class.as_str()).collect();
7109        for (a, b) in disjoint_pairs.iter().chain(equivalent_pairs) {
7110            focuses.insert(a.as_str());
7111            focuses.insert(b.as_str());
7112        }
7113
7114        let mut seen: BTreeSet<&str> = BTreeSet::new();
7115        for &c in &focuses {
7116            // reach(c) = {c} ∪ ancestors (capped BFS over `adj`).
7117            let mut reach: BTreeSet<&str> = BTreeSet::new();
7118            let mut q: VecDeque<&str> = VecDeque::new();
7119            reach.insert(c);
7120            q.push_back(c);
7121            while let Some(x) = q.pop_front() {
7122                if reach.len() > MAX_REACH {
7123                    break;
7124                }
7125                if let Some(ns) = adj.get(x) {
7126                    for &p in ns {
7127                        if reach.insert(p) {
7128                            q.push_back(p);
7129                        }
7130                    }
7131                }
7132            }
7133            for (x, y) in disjoint_pairs {
7134                if reach.contains(x.as_str()) && reach.contains(y.as_str()) && seen.insert(c) {
7135                    out.push(Inconsistency {
7136                        kind: "unsatisfiable-class",
7137                        detail: format!(
7138                            "{c} is a subclass of both {x} and {y}, which are \
7139                             owl:disjointWith — no individual can be a {c}"
7140                        ),
7141                    });
7142                    break;
7143                }
7144            }
7145        }
7146    }
7147
7148    out.sort_by(|a, b| (a.kind, &a.detail).cmp(&(b.kind, &b.detail)));
7149    out
7150}
7151
7152/// **Dictionary-free Tier-0 coherence read.** Fetch only the header and the
7153/// pyramid-meta range (2 small range reads) and run [`schema_coherence`] over the
7154/// schema pyramid — never touching the **dictionary** (which a literal-heavy file
7155/// makes large) or the triple index. `Ok(None)` if the file ships no pyramid.
7156///
7157/// This is what makes the Tier-0 check cheap on big graphs: the schema pyramid
7158/// carries its own class-string table, so coherence needs none of the dictionary.
7159pub fn read_schema_coherence_ranged<R: RangeReader>(
7160    reader: &R,
7161) -> Result<Option<Vec<crate::reason::Inconsistency>>, FileError> {
7162    let head = reader.read_at(0, HEADER_LEN as u64)?;
7163    let header = Header::from_bytes(&head)?;
7164    // The SCHEMA section (kind 9) first: one range, and no pyramid needed.
7165    if let Some(s) = read_schema_with_header(reader, &header) {
7166        return Ok(Some(schema_coherence(
7167            &s.class_hierarchy,
7168            &s.subclass_cycles,
7169            &s.disjoint_pairs,
7170            &s.equivalent_pairs,
7171        )));
7172    }
7173    if header.pyramid_meta_len == 0 {
7174        return Ok(None);
7175    }
7176    // Fast path: the header records the trailing schema block's length, so read ONLY
7177    // that block (at the end of pyramid-meta) — never the community summary, the
7178    // dictionary, or the index. This is what makes Tier-0 flat at any graph size.
7179    if header.schema_meta_len > 0 && (header.schema_meta_len as u64) <= header.pyramid_meta_len {
7180        let off =
7181            header.pyramid_meta_offset + header.pyramid_meta_len - header.schema_meta_len as u64;
7182        let block = reader.read_at(off, header.schema_meta_len as u64)?;
7183        let (hierarchy, cycles, disjoint, equivalent) = crate::meta::decode_schema_block(&block)
7184            .map_err(|_| FileError::Container("malformed schema block"))?;
7185        return Ok(Some(schema_coherence(
7186            &hierarchy,
7187            &cycles,
7188            &disjoint,
7189            &equivalent,
7190        )));
7191    }
7192    // Fallback (pre-v0.2.1 files with no header field): decode the whole pyramid-meta.
7193    let mb = if header.version == crate::v06::VERSION {
7194        crate::v06::open::read_pyramid(reader)?.unwrap_or_default()
7195    } else {
7196        reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?
7197    };
7198    let meta =
7199        PyramidMeta::decode(&mb).map_err(|_| FileError::Container("malformed pyramid meta"))?;
7200    Ok(Some(schema_coherence(
7201        &meta.class_hierarchy,
7202        &meta.subclass_cycles,
7203        &meta.disjoint_pairs,
7204        &meta.equivalent_pairs,
7205    )))
7206}
7207
7208/// The **schema summary** (per-class histogram + class relations at the finest
7209/// level) read over a [`RangeReader`] from the schema pyramid alone — the
7210/// index-free, range-readable source for a Schema view of a remote graph. Returns
7211/// `(classes, relations)` with `classes = [(class_iri, count)]` and `relations =
7212/// [(s_class, predicate, o_class, count)]`; `None` when the file has no schema
7213/// pyramid. Like [`read_schema_coherence_ranged`], it reads only the trailing
7214/// schema block, so it stays flat at any graph size.
7215#[allow(clippy::type_complexity)]
7216pub fn read_schema_summary_ranged<R: RangeReader>(
7217    reader: &R,
7218) -> Result<Option<(Vec<(String, u64)>, Vec<(String, String, String, u64)>)>, FileError> {
7219    let head = reader.read_at(0, HEADER_LEN as u64)?;
7220    let header = Header::from_bytes(&head)?;
7221    // The SCHEMA section (kind 9) first: one range, and no pyramid needed.
7222    let meta = if let Some(s) = read_schema_with_header(reader, &header) {
7223        PyramidMeta::new(0, Vec::new(), &[]).with_schema(
7224            Vec::new(),
7225            s.level_rollups,
7226            s.level_links,
7227            Vec::new(),
7228            Vec::new(),
7229            Vec::new(),
7230            Vec::new(),
7231        )
7232    } else {
7233        if header.pyramid_meta_len == 0 {
7234            return Ok(None);
7235        }
7236        if header.schema_meta_len > 0 && (header.schema_meta_len as u64) <= header.pyramid_meta_len
7237        {
7238            let off = header.pyramid_meta_offset + header.pyramid_meta_len
7239                - header.schema_meta_len as u64;
7240            let block = reader.read_at(off, header.schema_meta_len as u64)?;
7241            let summary = crate::meta::decode_schema_block_summary(&block)
7242                .map_err(|_| FileError::Container("malformed schema block"))?;
7243            return Ok(Some(summary));
7244        }
7245        // Fallback (pre-v0.2.1 files): decode the whole pyramid-meta, pull finest levels.
7246        let mb = if header.version == crate::v06::VERSION {
7247            crate::v06::open::read_pyramid(reader)?.unwrap_or_default()
7248        } else {
7249            reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?
7250        };
7251        PyramidMeta::decode(&mb).map_err(|_| FileError::Container("malformed pyramid meta"))?
7252    };
7253    if meta.level_rollups.is_empty() && meta.level_links.is_empty() {
7254        return Ok(None);
7255    }
7256    let classes = meta
7257        .level_rollups
7258        .iter()
7259        .max_by_key(|r| r.depth)
7260        .map(|r| r.classes.clone())
7261        .unwrap_or_default();
7262    let relations = meta
7263        .level_links
7264        .iter()
7265        .max_by_key(|l| l.depth)
7266        .map(|l| {
7267            l.links
7268                .iter()
7269                .map(|c| {
7270                    (
7271                        c.s_class.clone(),
7272                        c.predicate.clone(),
7273                        c.o_class.clone(),
7274                        c.count,
7275                    )
7276                })
7277                .collect()
7278        })
7279        .unwrap_or_default();
7280    Ok(Some((classes, relations)))
7281}
7282
7283/// Every dictionary section's chunk **routing keys**, exactly as stored, from a
7284/// finished file image. Test-only: it exists so a test in another module of
7285/// this crate (`extbuild`) can assert on the directory the writers produce
7286/// without any of this becoming public surface.
7287#[cfg(test)]
7288pub(crate) fn dict_chunk_keys_for_test(image: &[u8]) -> Vec<Vec<Vec<u8>>> {
7289    if crate::v06::open::is_v06(image) {
7290        // 0x06: the routing keys sit in the leaf directories of the dictionary.
7291        use crate::v06::{decode_chunk_leaf, decode_dict_root, kind, open, tag};
7292        let r = crate::reader::SliceReader::new(image);
7293        let (h, root) = open::read_front(&r).unwrap();
7294        let roots = decode_dict_root(root.unit(tag::DICT).unwrap()).unwrap();
7295        let leaves = open::read_section(&r, &h, &root, kind::DICT_LEAVES)
7296            .unwrap()
7297            .unwrap();
7298        return roots
7299            .iter()
7300            .map(|s| {
7301                s.leaves
7302                    .iter()
7303                    .flat_map(|(l, _, _, _)| {
7304                        let stored = &leaves[l.off as usize..(l.off + l.len) as usize];
7305                        let raw = decompress(h.internal_codec, stored).unwrap();
7306                        decode_chunk_leaf(&raw, l.n).unwrap()
7307                    })
7308                    .map(|c| c.key)
7309                    .collect()
7310            })
7311            .collect();
7312    }
7313    let header = Header::from_bytes(&image[..HEADER_LEN]).unwrap();
7314    let s = header.dictionary_offset as usize;
7315    let e = s + header.dictionary_len as usize;
7316    decode_container(&image[s..e], CODEC_NONE)
7317        .unwrap()
7318        .iter()
7319        .map(|payload| {
7320            let (_meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64).unwrap();
7321            entries.into_iter().map(|c| c.key).collect()
7322        })
7323        .collect()
7324}
7325
7326#[cfg(test)]
7327mod tests {
7328    use super::*;
7329    use super::{
7330        write_dataset_v05 as write_dataset,
7331        write_dataset_with_metadata_v05 as write_dataset_with_metadata,
7332        write_file_v05 as write_file,
7333    };
7334    use crate::dictionary::DictionaryBuilder;
7335    use crate::index::GraphIndexBuilder;
7336
7337    /// The pure-Rust decode path (wasm, `--no-default-features`) reads a zstd
7338    /// frame back byte for byte. Guards the `ruzstd` API across upgrades.
7339    #[cfg(not(feature = "compression"))]
7340    #[test]
7341    fn ruzstd_decodes_a_zstd_frame() {
7342        let data: Vec<u8> = (0..50_000u32)
7343            .flat_map(|i| (i % 251).to_le_bytes())
7344            .collect();
7345        let frame = ruzstd::encoding::compress_to_vec(
7346            &data[..],
7347            ruzstd::encoding::CompressionLevel::Fastest,
7348        );
7349        assert!(frame.len() < data.len());
7350        assert_eq!(decompress(CODEC_ZSTD, &frame).unwrap(), data);
7351    }
7352
7353    /// Bounded decompression: a frame whose header declares more than the
7354    /// bound is refused before decoding; one that states no size is decoded at
7355    /// most one byte past the bound; one that lies about its size fails; and
7356    /// the size a header states is read without decoding.
7357    #[cfg(feature = "compression")]
7358    #[test]
7359    fn decompression_is_bounded() {
7360        let data: Vec<u8> = (0..100_000u32).map(|i| (i % 251) as u8).collect();
7361        let sized = zstd::bulk::compress(&data, 3).unwrap();
7362        assert_eq!(zstd_frame_content_size(&sized), Some(100_000));
7363        assert_eq!(block_raw_len(CODEC_ZSTD, &sized, 1 << 20).unwrap(), 100_000);
7364        assert_eq!(
7365            decompress_bounded(CODEC_ZSTD, &sized, 100_000).unwrap(),
7366            data
7367        );
7368        assert!(decompress_bounded(CODEC_ZSTD, &sized, 99_999).is_err());
7369        // A streamed frame states no size: bounded by decoding.
7370        let stream = zstd::encode_all(&data[..], 3).unwrap();
7371        assert_eq!(zstd_frame_content_size(&stream), None);
7372        assert_eq!(
7373            block_raw_len(CODEC_ZSTD, &stream, 1 << 20).unwrap(),
7374            100_000
7375        );
7376        assert_eq!(
7377            decompress_bounded(CODEC_ZSTD, &stream, 100_000).unwrap(),
7378            data
7379        );
7380        assert!(decompress_bounded(CODEC_ZSTD, &stream, 50_000).is_err());
7381        // A header that under-states its content fails instead of growing.
7382        let mut liar = sized.clone();
7383        let fhd = liar[4];
7384        let fcs_at = 5 + usize::from(fhd & 0x20 == 0);
7385        liar[fcs_at] = liar[fcs_at].wrapping_sub(1);
7386        assert!(decompress_bounded(CODEC_ZSTD, &liar, 200_000).is_err());
7387        // Uncompressed blocks: the length is the size.
7388        assert!(decompress_bounded(CODEC_NONE, &data, 99_999).is_err());
7389        assert_eq!(
7390            decompress_bounded(CODEC_NONE, &data, 100_000).unwrap(),
7391            data
7392        );
7393        assert_eq!(zstd_frame_content_size(b"RETE"), None);
7394    }
7395
7396    /// A three-permutation file must survive every opener — resident, ranged,
7397    /// ranged-lazy — and answer every pattern shape with the rows the
7398    /// six-permutation twin gives, including through a named graph's own
7399    /// container. The mask is a file-level fact, so getting it to the named
7400    /// graphs' decoder is a separate thing that can be forgotten.
7401    #[test]
7402    fn three_permutation_file_round_trips_every_open_path() {
7403        use crate::index::PermSet;
7404        use crate::reader::SliceReader;
7405
7406        let build = |perms: PermSet| {
7407            let mut db = crate::DictionaryBuilder::new();
7408            let mut triples = Vec::new();
7409            for i in 0..60u32 {
7410                let (s, p, o) = (
7411                    format!("<http://ex/s{}>", i % 11),
7412                    format!("<http://ex/p{}>", i % 3),
7413                    format!("<http://ex/o{}>", i % 7),
7414                );
7415                db.observe(&s, &p, &o);
7416                triples.push((s, p, o));
7417            }
7418            let dict = db.build();
7419            let ids: Vec<(u32, u32, u32)> = triples
7420                .iter()
7421                .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
7422                .collect();
7423            let def = GraphIndexBuilder::from_triples(ids.clone())
7424                .with_tile_budget(96)
7425                .with_perms(perms)
7426                .build();
7427            let named = GraphIndexBuilder::from_triples(ids)
7428                .with_tile_budget(96)
7429                .with_perms(perms)
7430                .build();
7431            write_dataset(
7432                &dict,
7433                &def,
7434                &[("<http://ex/g>".to_string(), named)],
7435                true,
7436                &[],
7437                0,
7438            )
7439        };
7440
7441        let six = build(PermSet::ALL);
7442        let three = build(PermSet::CORE);
7443        assert!(three.len() < six.len());
7444        assert_eq!(Header::from_bytes(&three).unwrap().perms, PermSet::CORE);
7445        assert_eq!(Header::from_bytes(&six).unwrap().perms, PermSet::ALL);
7446
7447        let shapes: Vec<(Option<&str>, Option<&str>, Option<&str>)> = vec![
7448            (None, None, None),
7449            (Some("<http://ex/s3>"), None, None),
7450            (None, Some("<http://ex/p1>"), None),
7451            (None, None, Some("<http://ex/o5>")),
7452            (Some("<http://ex/s3>"), Some("<http://ex/p1>"), None),
7453            (Some("<http://ex/s3>"), None, Some("<http://ex/o5>")),
7454            (None, Some("<http://ex/p1>"), Some("<http://ex/o5>")),
7455            (
7456                Some("<http://ex/s3>"),
7457                Some("<http://ex/p1>"),
7458                Some("<http://ex/o5>"),
7459            ),
7460        ];
7461
7462        for (image, tag) in [(&six, "six"), (&three, "three")] {
7463            let resident = Rete::open(image).unwrap();
7464            let leaked: &'static [u8] = Box::leak(image.clone().into_boxed_slice());
7465            let ranged = Rete::open_ranged(&SliceReader::new(leaked)).unwrap();
7466            let lazy =
7467                Rete::open_ranged_lazy_with(SliceReader::new(leaked), HEADER_LEN as u64).unwrap();
7468            for (s, p, o) in &shapes {
7469                let want: Vec<_> = {
7470                    let mut v = Rete::open(&six).unwrap().query(*s, *p, *o);
7471                    v.sort();
7472                    v
7473                };
7474                for (got, path) in [
7475                    (resident.query(*s, *p, *o), "resident"),
7476                    (ranged.query(*s, *p, *o), "ranged"),
7477                    (lazy.query(*s, *p, *o), "lazy"),
7478                ] {
7479                    let mut got = got;
7480                    got.sort();
7481                    assert_eq!(got, want, "{tag}/{path} disagreed on {:?}", (s, p, o));
7482                }
7483                // The named graph's container carries the same mask.
7484                let mut g = resident.query_in_graph(Some("<http://ex/g>"), *s, *p, *o);
7485                g.sort();
7486                assert_eq!(g, want, "{tag}/named disagreed on {:?}", (s, p, o));
7487            }
7488        }
7489
7490        // And the single-pattern ROUTED read (the `query-url` path) locates its
7491        // section by container position, not by the format's fixed six-wide
7492        // slot — the one place the two indexes differ.
7493        let leaked: &'static [u8] = Box::leak(three.clone().into_boxed_slice());
7494        let reader = SliceReader::new(leaked);
7495        let routed = Rete::query_ranged(&reader, None, None, Some("<http://ex/o5>")).unwrap();
7496        let mut want = Rete::open(&six)
7497            .unwrap()
7498            .query(None, None, Some("<http://ex/o5>"));
7499        want.sort();
7500        let mut got = routed;
7501        got.sort();
7502        assert_eq!(got, want, "routed single-pattern read on a lean file");
7503    }
7504
7505    #[test]
7506    fn read_coalesced_merges_within_gap_and_splits_beyond() {
7507        use crate::reader::{CountingReader, SliceReader};
7508        let bytes = vec![0u8; 4096];
7509        // Three 16-byte ranges: A..B gap = 32, B..C gap = 1024.
7510        let ranges = [
7511            ByteRange { offset: 0, len: 16 },
7512            ByteRange {
7513                offset: 48,
7514                len: 16,
7515            },
7516            ByteRange {
7517                offset: 1088,
7518                len: 16,
7519            },
7520        ];
7521        // Tight gap (16): nothing merges → one read per range.
7522        let r = CountingReader::new(SliceReader::new(&bytes));
7523        let out = read_coalesced(&r, &ranges, 16).unwrap();
7524        assert_eq!(out.len(), 3);
7525        assert_eq!(r.requests(), 3);
7526        // Gap 64 merges A+B (gap 32) but not C (gap 1024) → two reads.
7527        let r = CountingReader::new(SliceReader::new(&bytes));
7528        read_coalesced(&r, &ranges, 64).unwrap();
7529        assert_eq!(r.requests(), 2);
7530        // Gap 4096 merges all three into one read, over-fetching the gaps.
7531        let r = CountingReader::new(SliceReader::new(&bytes));
7532        read_coalesced(&r, &ranges, 4096).unwrap();
7533        assert_eq!(r.requests(), 1);
7534    }
7535
7536    fn build_image() -> Vec<u8> {
7537        let triples = [
7538            ("Alice", "knows", "Bob"),
7539            ("Bob", "knows", "Carol"),
7540            ("Alice", "age", "30"),
7541        ];
7542        let mut db = DictionaryBuilder::new();
7543        for (s, p, o) in triples {
7544            db.observe(s, p, o);
7545        }
7546        let dict = db.build();
7547
7548        let mut ib = GraphIndexBuilder::new();
7549        for (s, p, o) in triples {
7550            ib.push(dict.encode(s, p, o).unwrap());
7551        }
7552        let index = ib.build();
7553
7554        let (meta, levels) = build_pyramid_meta(&dict, &triples_ids(&dict), DEFAULT_TILE_BUDGET);
7555        write_file(&dict, &index, false, &meta, levels)
7556    }
7557
7558    fn triples_ids(dict: &Dictionary) -> Vec<(u32, u32, u32)> {
7559        [
7560            ("Alice", "knows", "Bob"),
7561            ("Bob", "knows", "Carol"),
7562            ("Alice", "age", "30"),
7563        ]
7564        .iter()
7565        .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
7566        .collect()
7567    }
7568
7569    #[test]
7570    fn file_round_trips_header_and_counts() {
7571        let bytes = build_image();
7572        let rete = Rete::open(&bytes).unwrap();
7573        assert_eq!(rete.header().quad_count, 3);
7574        assert!(rete.header().term_count >= 5);
7575        let expected_codec = writer_codec();
7576        assert_eq!(rete.header().dict_codec, expected_codec);
7577        assert_eq!(rete.header().block_codec, expected_codec);
7578        assert_eq!(&bytes[bytes.len() - 4..], &MAGIC); // footer marker
7579    }
7580
7581    /// A file whose index was built with a tiny tile budget (forcing many
7582    /// tiles per permutation) must round-trip through write/open and answer
7583    /// every query shape identically — through both the in-memory and the
7584    /// routed ranged read paths.
7585    #[test]
7586    fn multi_tile_file_round_trips_and_routes() {
7587        let triples: Vec<(String, String, String)> = (0..200)
7588            .map(|i| {
7589                (
7590                    format!("<http://ex/s/{i}>"),
7591                    format!("<http://ex/p/{}>", i % 5),
7592                    format!("<http://ex/o/{}>", i % 23),
7593                )
7594            })
7595            .collect();
7596        let mut db = DictionaryBuilder::new();
7597        for (s, p, o) in &triples {
7598            db.observe(s, p, o);
7599        }
7600        let dict = db.build();
7601        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
7602        for (s, p, o) in &triples {
7603            ib.push(dict.encode(s, p, o).unwrap());
7604        }
7605        let index = ib.build();
7606        assert!(
7607            index.tile_sections()[0].len() > 3,
7608            "tiny budget must force many tiles"
7609        );
7610        let bytes = write_file(&dict, &index, false, &[], 0);
7611
7612        let rete = Rete::open(&bytes).unwrap();
7613        assert_eq!(rete.header().version, crate::header::LEGACY_FORMAT_VERSION);
7614        assert_eq!(rete.query(None, None, None).len(), 200);
7615        assert_eq!(rete.query(Some("<http://ex/s/7>"), None, None).len(), 1);
7616        assert_eq!(
7617            rete.query(None, Some("<http://ex/p/3>"), None).len(),
7618            40,
7619            "predicate extent spans tiles"
7620        );
7621        assert_eq!(
7622            rete.query(None, None, Some("<http://ex/o/22>")).len(),
7623            8 // 22, 45, 68, ... < 200
7624        );
7625
7626        // Routed ranged read must agree (and only decompress matching tiles).
7627        use crate::reader::SliceReader;
7628        let reader = SliceReader::new(&bytes);
7629        let routed = Rete::query_ranged(&reader, Some("<http://ex/s/7>"), None, None).unwrap();
7630        assert_eq!(routed.len(), 1);
7631        let routed = Rete::query_ranged(&reader, None, Some("<http://ex/p/3>"), None).unwrap();
7632        assert_eq!(routed.len(), 40);
7633        let routed = Rete::query_ranged(&reader, None, None, Some("<http://ex/o/22>")).unwrap();
7634        assert_eq!(routed.len(), 8);
7635    }
7636
7637    /// The tile-synopsis trailer round-trips through encode/parse, and each parsed
7638    /// synopsis is **exactly** the tile block's own b/c zone — so the directory
7639    /// can never prune a tile the tile itself would have matched.
7640    /// Section-internal byte offsets are u64: a directory whose tiles sit past
7641    /// 4 GiB must parse with exact offsets on EVERY platform. On wasm32 (32-bit
7642    /// usize) the old parse truncated a >4 GiB section length and rejected the
7643    /// tail ("dict chunk overruns section" on the first >4 GiB dictionary —
7644    /// crossref's 5.2 GB g.obj — the playground regression this guards).
7645    #[test]
7646    fn tile_directory_offsets_survive_past_4gib() {
7647        let mut dir = Vec::new();
7648        write_uvarint(&mut dir, 2); // two tiles
7649        write_uvarint(&mut dir, 5); // tile 1: Δmin_a
7650        write_uvarint(&mut dir, 0); //         span
7651        write_uvarint(&mut dir, 3 << 30); //   len = 3 GiB
7652        write_uvarint(&mut dir, 1); // tile 2: Δmin_a
7653        write_uvarint(&mut dir, 0);
7654        write_uvarint(&mut dir, 2 << 30); //   len = 2 GiB
7655        let total = dir.len() as u64 + (3u64 << 30) + (2u64 << 30) + 64;
7656        let entries = parse_tile_directory(&dir, total).unwrap();
7657        assert_eq!(entries.len(), 2);
7658        assert_eq!(entries[1].start, dir.len() as u64 + (3u64 << 30));
7659        assert!(
7660            entries[1].end > u32::MAX as u64,
7661            "tail tile sits past 4 GiB"
7662        );
7663        // a total smaller than the tiles must still reject the directory
7664        assert!(parse_tile_directory(&dir, 1 << 20).is_err());
7665    }
7666
7667    #[test]
7668    fn tile_synopsis_trailer_round_trips() {
7669        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
7670        for i in 0..200u32 {
7671            ib.push((i, i % 7, i % 13));
7672        }
7673        let index = ib.build();
7674        let tile_count = index.tile_sections()[0].len();
7675        assert!(tile_count > 3, "tiny budget forces many tiles");
7676
7677        let payload = encode_tiled_section(&index, 0, CODEC_NONE);
7678        let dir = parse_tile_directory(&payload, payload.len() as u64).unwrap();
7679        assert_eq!(dir.len(), tile_count);
7680        // The trailer sits past the last tile; the old directory parse stops there.
7681        let trailer_start = dir.iter().map(|e| e.end).max().unwrap();
7682        assert!(
7683            trailer_start < payload.len() as u64,
7684            "a trailer follows the tiles"
7685        );
7686        for e in &dir {
7687            assert!(
7688                e.end <= payload.len() as u64,
7689                "tiles still located within the payload"
7690            );
7691        }
7692        let syn = parse_tile_synopsis(&payload, trailer_start as usize, dir.len()).unwrap();
7693        for (e, (min_b, max_b, min_c, max_c)) in dir.iter().zip(syn) {
7694            let block = decompress(CODEC_NONE, &payload[e.start as usize..e.end as usize]).unwrap();
7695            let z = *crate::triples::TripleBlock::parse(&block).unwrap().zone();
7696            assert_eq!(
7697                (min_b, max_b, min_c, max_c),
7698                (z.min_b, z.max_b, z.min_c, z.max_c),
7699                "synopsis equals the tile's own zone"
7700            );
7701        }
7702    }
7703
7704    /// End-to-end safety: a synopsis-carrying file, opened **lazily** (range
7705    /// reads), must return exactly the brute-force answer for every pattern shape
7706    /// — the synopsis prune may never drop a real match.
7707    #[test]
7708    fn tile_synopsis_lazy_matches_reference_every_shape() {
7709        use crate::reader::{CountingReader, SliceReader};
7710        let triples: Vec<(String, String, String)> = (0..200u32)
7711            .map(|i| {
7712                (
7713                    format!("<http://ex/s/{i:04}>"),
7714                    format!("<http://ex/p/{}>", i % 7),
7715                    format!("<http://ex/o/{:04}>", i % 13),
7716                )
7717            })
7718            .collect();
7719        let mut db = DictionaryBuilder::new();
7720        for (s, p, o) in &triples {
7721            db.observe(s, p, o);
7722        }
7723        let dict = db.build();
7724        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
7725        for (s, p, o) in &triples {
7726            ib.push(dict.encode(s, p, o).unwrap());
7727        }
7728        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
7729
7730        let eager = Rete::open(&bytes).unwrap();
7731        assert!(
7732            eager.header().has_tile_synopsis(),
7733            "new files set the synopsis flag"
7734        );
7735
7736        // `open_ranged_lazy` needs a `'static` reader; leak the image (the test
7737        // process exits straight after).
7738        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
7739        let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
7740        let lazy = Rete::open_ranged_lazy_with(reader, HEADER_LEN as u64).unwrap();
7741
7742        let brute = |s: Option<&str>, p: Option<&str>, o: Option<&str>| {
7743            let mut v: Vec<(String, String, String)> = triples
7744                .iter()
7745                .filter(|(a, b, c)| {
7746                    s.is_none_or(|x| x == a) && p.is_none_or(|x| x == b) && o.is_none_or(|x| x == c)
7747                })
7748                .cloned()
7749                .collect();
7750            v.sort();
7751            v
7752        };
7753        // Existing + absent terms in every position (and unbound) — 4×4×4 shapes.
7754        let sv = [
7755            None,
7756            Some("<http://ex/s/0007>"),
7757            Some("<http://ex/s/0130>"),
7758            Some("<http://ex/s/9999>"),
7759        ];
7760        let pv = [
7761            None,
7762            Some("<http://ex/p/3>"),
7763            Some("<http://ex/p/6>"),
7764            Some("<http://ex/p/999>"),
7765        ];
7766        let ov = [
7767            None,
7768            Some("<http://ex/o/0000>"),
7769            Some("<http://ex/o/0012>"),
7770            Some("<http://ex/o/9999>"),
7771        ];
7772        for &s in &sv {
7773            for &p in &pv {
7774                for &o in &ov {
7775                    let mut e = eager.query(s, p, o);
7776                    e.sort();
7777                    let mut l = lazy.query(s, p, o);
7778                    l.sort();
7779                    let r = brute(s, p, o);
7780                    assert_eq!(e, r, "eager {s:?} {p:?} {o:?}");
7781                    assert_eq!(l, r, "lazy {s:?} {p:?} {o:?} — synopsis over-pruned");
7782                }
7783            }
7784        }
7785        assert!(!lazy.index_incomplete(), "no lazy fetch failed");
7786    }
7787
7788    /// Byte-0 / polyglot experiment: a real `.rete` embedded behind a large HTML
7789    /// shell (so byte 0 is `<`, not `RETE`) still opens and queries LAZILY through
7790    /// an `OffsetReader`, touching only the graph's bytes and never the shell.
7791    #[test]
7792    fn polyglot_offset_reads_lazily() {
7793        use crate::reader::{
7794            detect_polyglot_base, CountingReader, OffsetReader, SliceReader, POLYGLOT_DIGITS,
7795            POLYGLOT_MARKER,
7796        };
7797
7798        // A real .rete image, hidden behind a 50 KB HTML shell that carries the
7799        // polyglot base-offset marker in its first bytes (a browser-ignored
7800        // comment). The .rete begins right after the shell.
7801        let image = build_image();
7802        let mut shell = Vec::new();
7803        shell.extend_from_slice(b"<!DOCTYPE html><html><head><!--");
7804        shell.extend_from_slice(POLYGLOT_MARKER);
7805        let digits_at = shell.len();
7806        shell.extend_from_slice(&[b'0'; POLYGLOT_DIGITS]); // patched below
7807        shell.extend_from_slice(b"--></head><body>a web page</body></html>\n");
7808        shell.resize(50_000, b' '); // >> the .rete, to prove the shell is untouched
7809        let base = shell.len() as u64;
7810        let digits = format!("{base:0width$}", width = POLYGLOT_DIGITS);
7811        shell[digits_at..digits_at + POLYGLOT_DIGITS].copy_from_slice(digits.as_bytes());
7812
7813        let mut poly = shell;
7814        poly.extend_from_slice(&image);
7815
7816        // byte 0 is a web page, not a .rete...
7817        assert_ne!(
7818            &poly[0..4],
7819            b"RETE",
7820            "polyglot must not start with the magic"
7821        );
7822        // ...but the marker in the first header window points at the embedded .rete.
7823        let detected = detect_polyglot_base(&poly[..crate::header::HEADER_LEN]).unwrap();
7824        assert_eq!(detected, base);
7825
7826        // Open it LAZILY through the offset shim and query it.
7827        let leaked: &'static [u8] = Box::leak(poly.into_boxed_slice());
7828        let counting = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
7829        let lazy = Rete::open_ranged_lazy_with(
7830            OffsetReader::new(counting.clone(), detected),
7831            HEADER_LEN as u64,
7832        )
7833        .unwrap();
7834
7835        // The polyglot must answer exactly like the plain .rete it embeds.
7836        let plain = Rete::open(&image).unwrap();
7837        let mut want = plain.query(None, None, None);
7838        want.sort();
7839        let mut got = lazy.query(None, None, None);
7840        got.sort();
7841        assert_eq!(
7842            got, want,
7843            "polyglot lazy query differs from the plain .rete"
7844        );
7845        assert!(!got.is_empty(), "expected some triples");
7846        assert!(!lazy.index_incomplete(), "a lazy fetch failed");
7847
7848        // It was LAZY: it read fewer bytes than the HTML shell alone, i.e. it
7849        // never touched the prefix — only the embedded .rete's own bytes.
7850        let read = counting.bytes_read();
7851        eprintln!(
7852            "polyglot lazy read: HTML shell {base} B + .rete image {} B; \
7853             the query touched only {read} B (never the shell).",
7854            image.len()
7855        );
7856        assert!(
7857            read < base,
7858            "read {read} bytes; the HTML shell alone is {base} — the reader \
7859             touched the prefix instead of only the embedded .rete"
7860        );
7861    }
7862
7863    /// Build a small file whose objects are string literals, **with** a text
7864    /// index, and return `(image, triples)`. Shared by the text-index tests.
7865    #[cfg(test)]
7866    fn build_text_indexed(triples: &[(String, String, String)]) -> Vec<u8> {
7867        let mut db = DictionaryBuilder::new();
7868        for (s, p, o) in triples {
7869            db.observe(s, p, o);
7870        }
7871        let dict = db.build();
7872        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
7873        let mut id_triples: Vec<(u32, u32, u32)> = Vec::with_capacity(triples.len());
7874        for (s, p, o) in triples {
7875            let t = dict.encode(s, p, o).unwrap();
7876            ib.push(t);
7877            id_triples.push(t);
7878        }
7879        let index = ib.build();
7880        let text_index = compute_text_index(&dict, &id_triples);
7881        assert!(
7882            !text_index.is_empty(),
7883            "literals should produce a text index"
7884        );
7885        write_dataset_with_metadata(&dict, &index, &[], false, &[], 0, &[], &text_index)
7886    }
7887
7888    /// A `--text-index` build round-trips: `text_search` returns exactly the
7889    /// subjects whose literals contain the queried word(s), with AND across words
7890    /// and token-prefix — matching a brute-force scan of the literals.
7891    #[test]
7892    fn text_index_eager_matches_brute_force() {
7893        let triples: Vec<(String, String, String)> = vec![
7894            (
7895                "<http://ex/s0>",
7896                "<http://ex/label>",
7897                "\"alpha glucose phosphate\"",
7898            ),
7899            ("<http://ex/s1>", "<http://ex/label>", "\"beta Glucose\""),
7900            ("<http://ex/s2>", "<http://ex/label>", "\"gamma fructose\""),
7901            (
7902                "<http://ex/s3>",
7903                "<http://ex/note>",
7904                "\"einstein relativity\"",
7905            ),
7906            (
7907                "<http://ex/s4>",
7908                "<http://ex/ref>",
7909                "<http://ex/not-a-literal>",
7910            ),
7911        ]
7912        .into_iter()
7913        .map(|(s, p, o)| (s.to_string(), p.to_string(), o.to_string()))
7914        .collect();
7915        let bytes = build_text_indexed(&triples);
7916        let rete = Rete::open(&bytes).unwrap();
7917        assert!(rete.has_text_index());
7918
7919        // Brute-force reference: subjects whose literal objects contain all words.
7920        let brute = |words: &[&str]| -> Vec<String> {
7921            let mut v: Vec<String> = triples
7922                .iter()
7923                .filter(|(_, _, o)| {
7924                    crate::terms::is_literal(o)
7925                        && words.iter().all(|w| {
7926                            let wl = w.to_lowercase();
7927                            crate::terms::literal_lexical(o)
7928                                .unwrap()
7929                                .split(|c: char| !c.is_alphanumeric())
7930                                .any(|t| t.to_lowercase() == wl)
7931                        })
7932                })
7933                .map(|(s, _, _)| s.clone())
7934                .collect();
7935            v.sort();
7936            v.dedup();
7937            v
7938        };
7939
7940        let mut got = rete.text_search(&["glucose"], None, 0);
7941        got.sort();
7942        assert_eq!(got, brute(&["glucose"]), "case-insensitive single word");
7943
7944        // AND across two words: only s0 has both.
7945        let mut got = rete.text_search(&["glucose", "phosphate"], None, 0);
7946        got.sort();
7947        assert_eq!(got, brute(&["glucose", "phosphate"]));
7948
7949        // A word nobody has → empty.
7950        assert!(rete.text_search(&["zzznope"], None, 0).is_empty());
7951
7952        // Token-prefix: "ein…" matches "einstein".
7953        let got = rete.text_search(&[], Some("ein"), 0);
7954        assert_eq!(got, vec!["<http://ex/s3>".to_string()]);
7955
7956        // No text index → empty, has_text_index() false.
7957        let mut db = DictionaryBuilder::new();
7958        for (s, p, o) in &triples {
7959            db.observe(s, p, o);
7960        }
7961        let dict = db.build();
7962        let mut ib = GraphIndexBuilder::new();
7963        for (s, p, o) in &triples {
7964            ib.push(dict.encode(s, p, o).unwrap());
7965        }
7966        let plain = write_dataset(&dict, &ib.build(), &[], false, &[], 0);
7967        let plain_rete = Rete::open(&plain).unwrap();
7968        assert!(!plain_rete.has_text_index());
7969        assert!(plain_rete.text_search(&["glucose"], None, 0).is_empty());
7970    }
7971
7972    /// The lazy/remote path returns the same subjects as the eager path **and**
7973    /// faults only the token table + the queried posting list — never the whole
7974    /// postings blob. A `CountingReader` proves the byte budget stays small.
7975    #[test]
7976    fn text_index_lazy_faults_only_queried_postings() {
7977        use crate::reader::{CountingReader, SliceReader};
7978        // Many subjects so the postings blob is large relative to one posting:
7979        // every subject carries "common", but only a few carry "rare".
7980        let mut triples: Vec<(String, String, String)> = (0..300u32)
7981            .map(|i| {
7982                (
7983                    format!("<http://ex/s/{i:04}>"),
7984                    "<http://ex/label>".to_string(),
7985                    format!("\"common word number {i}\""),
7986                )
7987            })
7988            .collect();
7989        for i in [3u32, 77, 250] {
7990            triples.push((
7991                format!("<http://ex/s/{i:04}>"),
7992                "<http://ex/tag>".to_string(),
7993                "\"raretoken\"".to_string(),
7994            ));
7995        }
7996        let bytes = build_text_indexed(&triples);
7997        let eager = Rete::open(&bytes).unwrap();
7998        let mut want = eager.text_search(&["raretoken"], None, 0);
7999        want.sort();
8000        assert_eq!(want.len(), 3);
8001
8002        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
8003        let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
8004        let lazy = Rete::open_ranged_lazy_with(reader.clone(), HEADER_LEN as u64).unwrap();
8005        // Bytes pulled by the open itself (dict dirs, index dirs, named graphs) —
8006        // the text index is deferred and not touched yet.
8007        let before = reader.bytes_read();
8008        let mut got = lazy.text_search(&["raretoken"], None, 0);
8009        got.sort();
8010        assert_eq!(got, want, "lazy search matches eager");
8011        let pulled = reader.bytes_read() - before;
8012        // The search faulted the token table + the one "raretoken" posting; it must
8013        // be far less than the whole text-index section (300 "common" postings).
8014        let ti_len = eager.header().text_index_len;
8015        assert!(
8016            pulled < ti_len,
8017            "search pulled {pulled} B but the section is {ti_len} B — faulted too much"
8018        );
8019        assert!(!lazy.index_incomplete());
8020    }
8021
8022    /// `SearchView` answers exactly what a full open answers, having read
8023    /// strictly less: it skips the permutation tile directories and — the part
8024    /// that actually costs on a literal-heavy graph — the object-only
8025    /// dictionary's chunk directory.
8026    #[test]
8027    fn search_view_matches_full_open_for_far_fewer_bytes() {
8028        use crate::reader::{CountingReader, SliceReader};
8029        // Long object literals so the object-only dictionary (and its chunk
8030        // directory) dominates the file, as on a graph that stores abstracts.
8031        let filler = "lorem ipsum dolor sit amet consectetur adipiscing elit ".repeat(20);
8032        let mut triples: Vec<(String, String, String)> = (0..300u32)
8033            .map(|i| {
8034                (
8035                    format!("<http://ex/s/{i:04}>"),
8036                    "<http://ex/abstract>".to_string(),
8037                    format!("\"{filler} number {i}\""),
8038                )
8039            })
8040            .collect();
8041        for i in [3u32, 77, 250] {
8042            triples.push((
8043                format!("<http://ex/s/{i:04}>"),
8044                "<http://ex/tag>".to_string(),
8045                "\"raretoken\"".to_string(),
8046            ));
8047        }
8048        let bytes = build_text_indexed(&triples);
8049        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
8050
8051        let full_reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
8052        let full = Rete::open_ranged_lazy_with(full_reader.clone(), HEADER_LEN as u64).unwrap();
8053        let mut want = full.text_search(&["raretoken"], None, 0);
8054        want.sort();
8055        assert_eq!(want.len(), 3);
8056
8057        let search_reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
8058        let view = SearchView::open_ranged_with(search_reader.clone(), HEADER_LEN as u64).unwrap();
8059        assert!(view.has_text_index());
8060        let mut got = view.text_search(&["raretoken"], None, 0);
8061        got.sort();
8062        assert_eq!(got, want, "search view matches the full open");
8063        assert!(
8064            search_reader.bytes_read() < full_reader.bytes_read(),
8065            "search read {} B, full open read {} B — the narrow open bought nothing",
8066            search_reader.bytes_read(),
8067            full_reader.bytes_read()
8068        );
8069
8070        // A file without a text index says so from the header alone, and the
8071        // label-prefix mode still works through the same view.
8072        let mut prefix_hits = view.prefix_search("", 5);
8073        prefix_hits.sort();
8074        assert!(!prefix_hits.is_empty() || !view.has_pyramid());
8075    }
8076
8077    /// Nothing records a dictionary chunk directory's byte length, so a ranged
8078    /// open probes for it. That probe must cost about ONE directory, not several
8079    /// — it used to re-read the whole prefix on every round, so the sum was
8080    /// ~2x the last (already over-sized) read: 537 MB of range reads to fetch
8081    /// epfl-infoscience's 234 MB object-only directory. Here the same probe runs
8082    /// against a file whose object literals are long enough that its directory
8083    /// is well past the 8 KiB header prefix, and must (a) stay inside the
8084    /// append-only bound of 2x the directory and (b) beat what the old
8085    /// re-reading loop would have spent.
8086    #[test]
8087    fn dict_chunk_directory_probe_costs_about_one_directory() {
8088        use crate::reader::{CountingReader, SliceReader};
8089        // Long literals that share a 3 KiB prefix with their neighbour, each
8090        // ending in a 1 KiB tail of its own: the WKT-polygon / JSON-blob shape
8091        // (#198's weak case). The directory stores the SHORTEST SEPARATOR per
8092        // chunk, and a separator has to reproduce the whole shared prefix — so
8093        // these keys are still ~3 KiB and the directory still runs well past
8094        // the 8 KiB header prefix, which is what gives the probe something to
8095        // measure.
8096        //
8097        // This fixture used to put the varying bytes FIRST (`"{i:04} xxxx…"`).
8098        // That shape is the one separators annihilate: its keys collapse to ~4
8099        // bytes, the whole directory to 192 B, it fits the header prefix, and
8100        // the probe never runs — the assertion below then fails naming itself.
8101        // Keep the shared prefix.
8102        let head = "x".repeat(3000);
8103        let triples: Vec<(String, String, String)> = (0..2000u32)
8104            .map(|i| {
8105                let tail = char::from(b'a' + (i % 26) as u8).to_string().repeat(1000);
8106                (
8107                    format!("<http://ex/s/{i:04}>"),
8108                    "<http://ex/abstract>".to_string(),
8109                    format!("\"{head}{i:04}{tail}\""),
8110                )
8111            })
8112            .collect();
8113        let mut db = DictionaryBuilder::new();
8114        for (s, p, o) in &triples {
8115            db.observe(s, p, o);
8116        }
8117        let dict = db.build();
8118        let mut ib = GraphIndexBuilder::new();
8119        for (s, p, o) in &triples {
8120            ib.push(dict.encode(s, p, o).unwrap());
8121        }
8122        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
8123
8124        let reader = CountingReader::new(SliceReader::new(&bytes));
8125        let header = Header::from_bytes(&reader.read_at(0, HEADER_LEN as u64).unwrap()).unwrap();
8126        // Section 2 is object-only: the one carrying the long literals.
8127        let section = locate_container_section_ranged(
8128            &reader,
8129            header.dictionary_offset,
8130            header.dictionary_len,
8131            2,
8132            4,
8133        )
8134        .unwrap();
8135
8136        let before = reader.bytes_read();
8137        let (_meta, entries) = read_dict_dir_ranged(&reader, section).unwrap();
8138        let spent = reader.bytes_read() - before;
8139
8140        // The first chunk body starts where the directory ends.
8141        let dir_end = entries[0].start;
8142        let head = reader.read_at(section.offset, 64).unwrap();
8143        let (header_len, n0) = read_uvarint(&head).unwrap();
8144        let dir_start = n0 as u64 + header_len;
8145        let dir_len = dir_end - dir_start;
8146        let dir_total = section.len - dir_start;
8147        assert!(
8148            dir_start + dir_len > 8192,
8149            "directory fits the header prefix ({dir_len} B) — the probe never runs"
8150        );
8151
8152        // What the previous loop spent: every round re-read from the start.
8153        let mut old = 0u64;
8154        let mut p = 4096u64.min(dir_total).max(1);
8155        loop {
8156            let got = p.min(dir_total);
8157            old += got;
8158            if got >= dir_len || p >= dir_total {
8159                break;
8160            }
8161            p = p.saturating_mul(2).min(dir_total);
8162        }
8163        assert!(
8164            spent <= dir_len * 2,
8165            "probe read {spent} B for a {dir_len} B directory — past the append-only bound"
8166        );
8167        assert!(
8168            spent < old,
8169            "probe read {spent} B; the re-reading loop it replaced spent {old} B"
8170        );
8171    }
8172
8173    // ---------------------------------------------------------------------
8174    // Separator routing keys (#198 option (d))
8175    // ---------------------------------------------------------------------
8176
8177    /// A verbatim-keyed twin of a chunked dictionary section: same header, same
8178    /// chunking, byte-identical compressed bodies — only every directory key
8179    /// replaced by the chunk's actual first term. That is exactly what
8180    /// `encode_chunked_dict_section` wrote before separator keys, so it is the
8181    /// control for every comparison below (and for the proptest).
8182    fn verbatim_keyed_twin(payload: &[u8], codec: u8) -> Vec<u8> {
8183        let (_meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64).unwrap();
8184        let (header_len, n0) = read_uvarint(payload).unwrap();
8185        let mut out = Vec::new();
8186        write_uvarint(&mut out, header_len);
8187        out.extend_from_slice(&payload[n0..n0 + header_len as usize]);
8188        write_uvarint(&mut out, entries.len() as u64);
8189        let mut prev_run = 0usize;
8190        for e in &entries {
8191            let body = decompress(codec, &payload[e.start as usize..e.end as usize]).unwrap();
8192            let first = crate::dict::run_first_term(&body, 0).unwrap();
8193            write_uvarint(&mut out, (e.first_run - prev_run) as u64);
8194            write_uvarint(&mut out, first.len() as u64);
8195            out.extend_from_slice(&first);
8196            write_uvarint(&mut out, e.end - e.start);
8197            prev_run = e.first_run;
8198        }
8199        for e in &entries {
8200            out.extend_from_slice(&payload[e.start as usize..e.end as usize]);
8201        }
8202        out
8203    }
8204
8205    /// Each chunk's (first term, last term), decoded from the bodies.
8206    fn chunk_bounds_of(payload: &[u8], codec: u8) -> Vec<(Vec<u8>, Vec<u8>)> {
8207        let (_meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64).unwrap();
8208        entries
8209            .iter()
8210            .map(|e| {
8211                let body = decompress(codec, &payload[e.start as usize..e.end as usize]).unwrap();
8212                (
8213                    crate::dict::run_first_term(&body, 0).unwrap(),
8214                    crate::dict::run_last_term(&body, 0, body.len()).unwrap(),
8215                )
8216            })
8217            .collect()
8218    }
8219
8220    /// A term set built to hit every shape the corpus has: plain IRIs, literals
8221    /// sharing a long prefix with their neighbour (separators are LONG there —
8222    /// the WKT/`ohm-full` weak case), literals that diverge in their first bytes
8223    /// (separators are tiny — the `epfl-infoscience` case), literals larger than
8224    /// `DICT_CHUNK_BUDGET` on their own, and multibyte UTF-8.
8225    fn adversarial_terms() -> Vec<String> {
8226        let mut terms: Vec<String> = Vec::new();
8227        for i in 0..6_000 {
8228            terms.push(format!("<http://example.org/work/{i:06}>"));
8229        }
8230        let shared = "X".repeat(900);
8231        for i in 0..4_000 {
8232            terms.push(format!("\"{shared}-{i:05}-tail\""));
8233        }
8234        let deep = "Y".repeat(3_000); // shared prefix far past any short key
8235        for i in 0..120 {
8236            terms.push(format!("\"{deep}{i:03}\""));
8237        }
8238        for i in 0..6 {
8239            // one literal larger than the 64 KiB budget: a chunk of one run
8240            terms.push(format!("\"BIG-{i:02}-{}\"", "Z".repeat(90_000)));
8241        }
8242        for i in 0..800 {
8243            terms.push(format!(
8244                "\"h\u{e9}llo-{}-\u{1F600}{i:04}\"",
8245                "\u{e9}".repeat(40)
8246            ));
8247        }
8248        for i in 0..2_000 {
8249            terms.push(format!("\"{i:05}-{}\"", "T".repeat(1_800)));
8250        }
8251        terms.sort();
8252        terms.dedup();
8253        terms
8254    }
8255
8256    fn chunked_section_of(terms: &[String]) -> Vec<u8> {
8257        let mut b = crate::dict::DictSectionBuilder::new().with_restart_interval(16);
8258        for t in terms {
8259            b.push(t.clone());
8260        }
8261        b.build()
8262    }
8263
8264    /// The invariant, asserted against the real chunk contents: the stored key
8265    /// of chunk `i` is strictly above chunk `i-1`'s LAST term and at most chunk
8266    /// `i`'s own first term, chunk 0's key is empty, and the keys ascend — the
8267    /// three facts `ChunkedSection::id`'s `partition_point` relies on.
8268    ///
8269    /// Plus the anti-regression the rename exists for: on a section like this
8270    /// the key must NOT be the first term. A writer that quietly went back to
8271    /// storing terms would pass every other test in this file — dumps and
8272    /// `term(id)` route by `first_run` and never read the key.
8273    #[test]
8274    fn chunk_directory_keys_are_separators_not_terms() {
8275        let terms = adversarial_terms();
8276        let raw = chunked_section_of(&terms);
8277        let payload = encode_chunked_dict_section(&raw, CODEC_NONE);
8278        let (_meta, entries) = parse_chunked_dict_dir(&payload, payload.len() as u64).unwrap();
8279        let bounds = chunk_bounds_of(&payload, CODEC_NONE);
8280        assert!(
8281            entries.len() > 20,
8282            "need a many-chunk section, got {}",
8283            entries.len()
8284        );
8285
8286        assert!(entries[0].key.is_empty(), "chunk 0 needs no separator");
8287        let mut key_bytes = 0usize;
8288        let mut term_bytes = 0usize;
8289        let mut differ = 0usize;
8290        for (i, e) in entries.iter().enumerate() {
8291            key_bytes += e.key.len();
8292            term_bytes += bounds[i].0.len();
8293            if e.key != bounds[i].0 {
8294                differ += 1;
8295            }
8296            assert!(
8297                e.key <= bounds[i].0,
8298                "chunk {i}: key must be <= its own first term"
8299            );
8300            if i > 0 {
8301                assert!(
8302                    e.key.as_slice() > bounds[i - 1].1.as_slice(),
8303                    "chunk {i}: key must be > the previous chunk's last term"
8304                );
8305                assert!(
8306                    e.key > entries[i - 1].key,
8307                    "chunk {i}: keys must ascend for partition_point"
8308                );
8309                // shortest: one byte past the divergence from the predecessor
8310                assert_eq!(
8311                    e.key,
8312                    crate::dict::shortest_separator(&bounds[i - 1].1, &bounds[i].0),
8313                    "chunk {i}: key is not the SHORTEST separator"
8314                );
8315            }
8316        }
8317        assert!(
8318            differ > 0,
8319            "every key equalled its first term — the writer stores terms again"
8320        );
8321        assert!(
8322            key_bytes * 4 < term_bytes,
8323            "separators bought almost nothing: {key_bytes} B vs {term_bytes} B of first terms"
8324        );
8325        eprintln!(
8326            "chunks={} first-term keys={term_bytes} B  separator keys={key_bytes} B ({:.1}x)",
8327            entries.len(),
8328            term_bytes as f64 / key_bytes.max(1) as f64
8329        );
8330    }
8331
8332    /// A separator-keyed section answers **identically** to the verbatim-keyed
8333    /// one built over the same bodies, under the same unmodified reader: every
8334    /// id, every term, and every boundary probe — the first and last term of
8335    /// each chunk, one byte either side of each, strings that fall strictly
8336    /// *between* two chunks, truncations, the empty string, and probes below
8337    /// and above the whole section.
8338    ///
8339    /// Probing `id(term)` is the entire point. A broken key (a truncation, a
8340    /// hash) leaves `term(id)`, `dump` and `export` byte-perfect — they route by
8341    /// `first_run` — and only lookups lie.
8342    #[test]
8343    fn separator_keys_route_identically_to_verbatim_first_terms() {
8344        let terms = adversarial_terms();
8345        let raw = chunked_section_of(&terms);
8346        let sep_payload = encode_chunked_dict_section(&raw, CODEC_NONE);
8347        let ver_payload = verbatim_keyed_twin(&sep_payload, CODEC_NONE);
8348
8349        // Same chunking, byte-identical bodies — only the keys differ.
8350        let (_ms, es) = parse_chunked_dict_dir(&sep_payload, sep_payload.len() as u64).unwrap();
8351        let (_mv, ev) = parse_chunked_dict_dir(&ver_payload, ver_payload.len() as u64).unwrap();
8352        assert_eq!(es.len(), ev.len());
8353        for (a, b) in es.iter().zip(ev.iter()) {
8354            assert_eq!(a.first_run, b.first_run);
8355            assert_eq!(
8356                &sep_payload[a.start as usize..a.end as usize],
8357                &ver_payload[b.start as usize..b.end as usize],
8358                "chunk bodies must be identical"
8359            );
8360        }
8361        assert!(
8362            sep_payload.len() < ver_payload.len(),
8363            "separator payload is not smaller"
8364        );
8365
8366        let sec_s = decode_chunked_dict_section(
8367            &sep_payload,
8368            CODEC_NONE,
8369            crate::chunk_cache::ChunkCache::unlimited_arc(),
8370            0,
8371        )
8372        .unwrap();
8373        let sec_v = decode_chunked_dict_section(
8374            &ver_payload,
8375            CODEC_NONE,
8376            crate::chunk_cache::ChunkCache::unlimited_arc(),
8377            0,
8378        )
8379        .unwrap();
8380
8381        // 1. every present term → the same, correct id
8382        for (i, t) in terms.iter().enumerate() {
8383            let want = Some(i as u32 + 1);
8384            assert_eq!(sec_v.id(t), want, "verbatim id() lost a term");
8385            assert_eq!(sec_s.id(t), want, "separator id() lost a term");
8386        }
8387        // 2. every id → the same term
8388        for i in 0..terms.len() as u32 {
8389            assert_eq!(sec_s.term(i + 1), sec_v.term(i + 1));
8390            assert_eq!(
8391                sec_s.term(i + 1).as_deref(),
8392                Some(terms[i as usize].as_str())
8393            );
8394        }
8395        // 3. boundary probes, from the real chunk bounds
8396        let bounds = chunk_bounds_of(&sep_payload, CODEC_NONE);
8397        let mut probes: Vec<String> = Vec::new();
8398        let push = |bytes: &[u8], probes: &mut Vec<String>| {
8399            if let Ok(s) = std::str::from_utf8(bytes) {
8400                probes.push(s.to_string());
8401            }
8402        };
8403        for (first, last) in &bounds {
8404            for base in [first, last] {
8405                push(base, &mut probes); // present
8406                let mut after = base.clone();
8407                after.push(0x01); // immediately after — between chunks when base = last
8408                push(&after, &mut probes);
8409                let mut far = base.clone();
8410                far.push(b'~');
8411                push(&far, &mut probes);
8412                if !base.is_empty() {
8413                    push(&base[..base.len() - 1], &mut probes); // truncated
8414                    let mut down = base.clone();
8415                    *down.last_mut().unwrap() = down.last().unwrap().wrapping_sub(1);
8416                    push(&down, &mut probes);
8417                    let mut up = base.clone();
8418                    *up.last_mut().unwrap() = up.last().unwrap().wrapping_add(1);
8419                    push(&up, &mut probes);
8420                }
8421            }
8422        }
8423        // 4. the stored separators themselves, and the ends of the world
8424        for e in &es {
8425            push(&e.key, &mut probes);
8426            let mut plus = e.key.clone();
8427            plus.push(0);
8428            push(&plus, &mut probes);
8429        }
8430        probes.push(String::new()); // the empty key, i.e. chunk 0's
8431        probes.push("\u{1}".to_string()); // below every term
8432        probes.push("\u{10FFFF}\u{10FFFF}".to_string()); // above every term
8433        probes.push(terms.first().unwrap().clone());
8434        probes.push(terms.last().unwrap().clone());
8435
8436        let mut hits = 0usize;
8437        for p in &probes {
8438            let (a, b) = (sec_v.id(p), sec_s.id(p));
8439            assert_eq!(
8440                a,
8441                b,
8442                "probe diverged: {:?}",
8443                p.get(..p.len().min(48)).unwrap_or(p.as_str())
8444            );
8445            hits += usize::from(a.is_some());
8446        }
8447        assert!(
8448            probes.len() > 500 && hits > 50,
8449            "probe set is too thin: {} probes, {hits} of them present",
8450            probes.len()
8451        );
8452        eprintln!(
8453            "{} boundary probes, {hits} present, all identical",
8454            probes.len()
8455        );
8456    }
8457
8458    /// The same claim as a **property**: for arbitrary term sets and arbitrary
8459    /// probes, a separator-keyed section and the verbatim-keyed twin built from
8460    /// the same input are indistinguishable through the reader. The fixed test
8461    /// above picks the shapes I thought of; this one shrinks a counterexample
8462    /// out of the ones I did not.
8463    mod separator_props {
8464        use super::*;
8465        use proptest::prelude::*;
8466
8467        /// Sorted, deduplicated term sets mixing the three shapes that decide a
8468        /// separator's length: short IRIs, literals whose payload comes BEFORE
8469        /// the discriminating bytes (long shared prefixes ⇒ long separators),
8470        /// and literals that diverge immediately (⇒ one-byte separators). Sizes
8471        /// straddle `DICT_CHUNK_BUDGET`, so single- and many-chunk sections both
8472        /// occur.
8473        fn term_set() -> impl Strategy<Value = Vec<String>> {
8474            prop::collection::vec(("[a-c]{1,6}", 0u8..3, 300usize..1200), 60..250).prop_map(
8475                |specs| {
8476                    let mut terms: Vec<String> = specs
8477                        .into_iter()
8478                        .enumerate()
8479                        .map(|(i, (core, kind, pad))| {
8480                            let block = "z".repeat(pad);
8481                            match kind {
8482                                0 => format!("<http://ex/{core}/{i:05}>"),
8483                                1 => format!("\"{block}{core}{i:05}\""),
8484                                _ => format!("\"{core}{i:05}{block}\""),
8485                            }
8486                        })
8487                        .collect();
8488                    terms.sort();
8489                    terms.dedup();
8490                    terms
8491                },
8492            )
8493        }
8494
8495        proptest! {
8496            #[test]
8497            fn prop_separator_keyed_section_answers_like_verbatim(
8498                terms in term_set(),
8499                extra in prop::collection::vec(any::<String>(), 0..8),
8500            ) {
8501                let raw = chunked_section_of(&terms);
8502                let sep = encode_chunked_dict_section(&raw, CODEC_NONE);
8503                let ver = verbatim_keyed_twin(&sep, CODEC_NONE);
8504                prop_assert!(sep.len() <= ver.len());
8505
8506                // The separator invariant, against the real chunk contents.
8507                let (_m, es) = parse_chunked_dict_dir(&sep, sep.len() as u64).unwrap();
8508                let bounds = chunk_bounds_of(&sep, CODEC_NONE);
8509                prop_assert!(es[0].key.is_empty());
8510                for i in 1..es.len() {
8511                    prop_assert!(es[i].key.as_slice() > bounds[i - 1].1.as_slice());
8512                    prop_assert!(es[i].key <= bounds[i].0);
8513                    prop_assert!(es[i].key > es[i - 1].key);
8514                }
8515
8516                let sec_s = decode_chunked_dict_section(
8517                    &sep,
8518                    CODEC_NONE,
8519                    crate::chunk_cache::ChunkCache::unlimited_arc(),
8520                    0,
8521                )
8522                .unwrap();
8523                let sec_v = decode_chunked_dict_section(
8524                    &ver,
8525                    CODEC_NONE,
8526                    crate::chunk_cache::ChunkCache::unlimited_arc(),
8527                    0,
8528                )
8529                .unwrap();
8530
8531                for (i, t) in terms.iter().enumerate() {
8532                    prop_assert_eq!(sec_s.id(t), Some(i as u32 + 1));
8533                    prop_assert_eq!(sec_v.id(t), Some(i as u32 + 1));
8534                    prop_assert_eq!(sec_s.term(i as u32 + 1), sec_v.term(i as u32 + 1));
8535                }
8536
8537                // Probes: arbitrary strings, plus mutations of every boundary.
8538                let mut probes: Vec<String> = extra;
8539                probes.push(String::new());
8540                for (first, last) in &bounds {
8541                    for base in [first, last] {
8542                        if let Ok(s) = std::str::from_utf8(base) {
8543                            probes.push(s.to_string());
8544                            probes.push(format!("{s}\u{1}"));
8545                            probes.push(s[..s.len() - s.chars().next_back()
8546                                .map(char::len_utf8).unwrap_or(0)].to_string());
8547                        }
8548                    }
8549                }
8550                for e in &es {
8551                    if let Ok(s) = std::str::from_utf8(&e.key) {
8552                        probes.push(s.to_string());
8553                    }
8554                }
8555                for p in &probes {
8556                    prop_assert_eq!(sec_s.id(p), sec_v.id(p), "probe diverged: {:?}", p);
8557                }
8558            }
8559        }
8560    }
8561
8562    /// The TEXT_INDEX section is inside the content hash: a freshly built
8563    /// text-indexed file must pass `verify()`, and flipping a byte inside the
8564    /// section must break it. (Regression: `verify()` once rebuilt the hash
8565    /// without the text index, so every `--text-index` file failed as corrupt.)
8566    #[test]
8567    fn text_index_is_tamper_evident_and_verifies() {
8568        let triples: Vec<(String, String, String)> = vec![(
8569            "<http://ex/s0>".to_string(),
8570            "<http://ex/label>".to_string(),
8571            "\"alpha glucose phosphate\"".to_string(),
8572        )];
8573        let bytes = build_text_indexed(&triples);
8574        let header = Rete::open(&bytes).unwrap().header().clone();
8575        assert!(header.text_index_len > 0);
8576        assert!(verify(&bytes).unwrap(), "a text-indexed build must verify");
8577
8578        let mut tampered = bytes.clone();
8579        tampered[header.text_index_offset as usize] ^= 0xff;
8580        assert!(
8581            !verify(&tampered).unwrap(),
8582            "tampering with the text index must break verify()"
8583        );
8584    }
8585
8586    /// End-to-end win: on a remote (range-read) file, a lookup whose routed tile
8587    /// is ruled out by a bound secondary fetches **fewer bytes** with the synopsis
8588    /// than without it — and the answer is identical (empty) either way.
8589    #[test]
8590    fn synopsis_cuts_remote_fetch_bytes() {
8591        use crate::header::FLAG_TILE_SYNOPSIS;
8592        use crate::reader::{CountingReader, SliceReader};
8593
8594        // Zero-padded terms ⇒ dictionary ids are monotonic in i; subject s_i pairs
8595        // only with object o_i, so an OSP tile (routed by object) holds a
8596        // contiguous subject range — a subject from a far tile is provably absent.
8597        let triples: Vec<(String, String, String)> = (0..400u32)
8598            .map(|i| {
8599                (
8600                    format!("<http://ex/s/{i:04}>"),
8601                    "<http://ex/p>".to_string(),
8602                    format!("<http://ex/o/{i:04}>"),
8603                )
8604            })
8605            .collect();
8606        let mut db = DictionaryBuilder::new();
8607        for (s, p, o) in &triples {
8608            db.observe(s, p, o);
8609        }
8610        let dict = db.build();
8611        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
8612        for (s, p, o) in &triples {
8613            ib.push(dict.encode(s, p, o).unwrap());
8614        }
8615        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
8616
8617        // (s_0395, ?, o_0005): routes OSP by the (early) object, secondary = the
8618        // (late) subject — outside that tile's subject range, so the synopsis
8619        // prunes the one routed tile.
8620        let q = (Some("<http://ex/s/0395>"), None, Some("<http://ex/o/0005>"));
8621        // Measure the bytes the QUERY pulls (after open) — isolating the per-query
8622        // saving from the one-time synopsis trailer reads done at open, which a
8623        // persistent remote session amortizes over many queries.
8624        let query_bytes = |image: &[u8]| -> (u64, usize) {
8625            let leaked: &'static [u8] = Box::leak(image.to_vec().into_boxed_slice());
8626            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
8627            let rete = Rete::open_ranged_lazy_with(reader.clone(), HEADER_LEN as u64).unwrap();
8628            let before = reader.bytes_read(); // after open (incl. trailer reads)
8629            let n = rete.query(q.0, q.1, q.2).len();
8630            assert!(!rete.index_incomplete());
8631            (reader.bytes_read() - before, n)
8632        };
8633
8634        let (on_bytes, on_n) = query_bytes(&bytes);
8635        // Same file with the synopsis flag cleared = an older reader's behavior.
8636        let mut off = bytes.clone();
8637        off[5] &= !FLAG_TILE_SYNOPSIS;
8638        let (off_bytes, off_n) = query_bytes(&off);
8639
8640        assert_eq!(on_n, 0, "the pair never co-occurs");
8641        assert_eq!(off_n, 0, "same answer without the synopsis");
8642        // Both pay the same dictionary-resolution bytes; the difference is the one
8643        // routed index tile that the synopsis skips (and the no-synopsis path
8644        // fetches only to have its zone map reject it).
8645        assert!(
8646            on_bytes < off_bytes,
8647            "synopsis skips the routed tile fetch: {on_bytes} < {off_bytes}"
8648        );
8649    }
8650
8651    /// A double-bound-object intersection (`?p P o1 ; P o2 ; label ?l`) — the
8652    /// shape whose REMOTE join strategy changed (scan + hash-join instead of
8653    /// probing each prefix row) — must return the SAME rows opened eagerly (in
8654    /// memory) and lazily (remote-style, `is_remote()` true). Strategy is a
8655    /// performance choice; the result multiset is invariant.
8656    #[test]
8657    fn double_bound_object_join_eager_matches_lazy() {
8658        use crate::reader::SliceReader;
8659        let occ = "<http://ex/occ>";
8660        let phys = "<http://ex/physicist>";
8661        let phil = "<http://ex/philosopher>";
8662        let label = "<http://www.w3.org/2000/01/rdf-schema#label>";
8663        // p00..p19 are physicists; p00..p09 are also philosophers (the answer).
8664        let mut triples: Vec<(String, String, String)> = Vec::new();
8665        for i in 0..20u32 {
8666            triples.push((format!("<http://ex/p/{i:02}>"), occ.into(), phys.into()));
8667            if i < 10 {
8668                triples.push((format!("<http://ex/p/{i:02}>"), occ.into(), phil.into()));
8669            }
8670            triples.push((
8671                format!("<http://ex/p/{i:02}>"),
8672                label.into(),
8673                format!("\"Name {i:02}\""),
8674            ));
8675        }
8676        let mut db = DictionaryBuilder::new();
8677        for (s, p, o) in &triples {
8678            db.observe(s, p, o);
8679        }
8680        let dict = db.build();
8681        let mut ib = GraphIndexBuilder::new().with_tile_budget(16);
8682        for (s, p, o) in &triples {
8683            ib.push(dict.encode(s, p, o).unwrap());
8684        }
8685        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
8686
8687        let q = "SELECT ?l WHERE { \
8688            ?p <http://ex/occ> <http://ex/physicist> ; \
8689               <http://ex/occ> <http://ex/philosopher> ; \
8690               <http://www.w3.org/2000/01/rdf-schema#label> ?l }";
8691        let run = |rete: &Rete| -> Vec<String> {
8692            let (_, sols) = crate::eval_sparql(rete, q).unwrap();
8693            let mut v: Vec<String> = sols.iter().filter_map(|b| b.get("l").cloned()).collect();
8694            v.sort();
8695            v
8696        };
8697
8698        let eager_rows = run(&Rete::open(&bytes).unwrap());
8699        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
8700        let lazy = Rete::open_ranged_lazy_with(
8701            std::sync::Arc::new(SliceReader::new(leaked)),
8702            HEADER_LEN as u64,
8703        )
8704        .unwrap();
8705        let lazy_rows = run(&lazy);
8706        assert!(!lazy.index_incomplete());
8707
8708        assert_eq!(eager_rows.len(), 10, "the 10 physicist∩philosopher labels");
8709        assert_eq!(eager_rows, lazy_rows, "eager and lazy must agree exactly");
8710    }
8711
8712    /// A dictionary big enough to split into multiple chunks per section must
8713    /// round-trip every id↔term mapping through the chunked (v0.2) encoding —
8714    /// including terms at chunk boundaries and absent near-misses.
8715    #[test]
8716    fn multi_chunk_dictionary_round_trips() {
8717        let mut db = DictionaryBuilder::new();
8718        let term = |i: u32| format!("<http://example.org/some/long/prefix/entity/{i:06}>");
8719        for i in 0..6000u32 {
8720            db.observe(&term(i), "<http://ex/p>", &term(i + 1));
8721        }
8722        let dict = db.build();
8723        let mut ib = GraphIndexBuilder::new();
8724        for i in 0..6000u32 {
8725            ib.push(
8726                dict.encode(&term(i), "<http://ex/p>", &term(i + 1))
8727                    .unwrap(),
8728            );
8729        }
8730        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
8731        let rete = Rete::open(&bytes).unwrap();
8732        let d = rete.dictionary();
8733        assert_eq!(d.term_count(), dict.term_count());
8734        for i in (0..6000).step_by(97).chain([0, 1, 5999, 6000]) {
8735            let t = term(i);
8736            let sid = dict.subject_id(&t);
8737            assert_eq!(d.subject_id(&t), sid, "subject_id({t})");
8738            if let Some(id) = sid {
8739                assert_eq!(d.subject_term(id).as_deref(), Some(t.as_str()));
8740            }
8741            let oid = dict.object_id(&t);
8742            assert_eq!(d.object_id(&t), oid, "object_id({t})");
8743        }
8744        assert_eq!(d.subject_id("<http://example.org/absent>"), None);
8745        assert_eq!(d.predicate_id("<http://ex/p>"), Some(1));
8746        assert_eq!(d.predicate_term(1).as_deref(), Some("<http://ex/p>"));
8747    }
8748
8749    #[test]
8750    #[cfg(feature = "compression")]
8751    fn compression_shrinks_repetitive_data() {
8752        // Many triples sharing IRI prefixes — exactly what front-coding + zstd
8753        // should crush. The compressed file must be far smaller than the raw
8754        // term bytes, and still query correctly.
8755        let mut db = DictionaryBuilder::new();
8756        let triples: Vec<(String, String, String)> = (0..500)
8757            .map(|i| {
8758                (
8759                    format!("<http://example.org/entity/{i}>"),
8760                    "<http://example.org/p/relatedTo>".to_string(),
8761                    format!("<http://example.org/entity/{}>", (i + 1) % 500),
8762                )
8763            })
8764            .collect();
8765        for (s, p, o) in &triples {
8766            db.observe(s, p, o);
8767        }
8768        let dict = db.build();
8769        let mut ib = GraphIndexBuilder::new();
8770        for (s, p, o) in &triples {
8771            ib.push(dict.encode(s, p, o).unwrap());
8772        }
8773        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
8774
8775        let raw: usize = triples
8776            .iter()
8777            .map(|(s, p, o)| s.len() + p.len() + o.len())
8778            .sum();
8779        assert!(
8780            bytes.len() < raw / 2,
8781            "expected strong compression: file {} vs raw terms {raw}",
8782            bytes.len()
8783        );
8784
8785        // Still queryable after compression.
8786        let rete = Rete::open(&bytes).unwrap();
8787        let r = rete.query(Some("<http://example.org/entity/0>"), None, None);
8788        assert_eq!(r.len(), 1);
8789        assert_eq!(r[0].2, "<http://example.org/entity/1>");
8790    }
8791
8792    fn big_file_with_pyramid() -> Vec<u8> {
8793        // A ring of 300 entities -> index dwarfs dict+meta.
8794        let triples: Vec<(String, String, String)> = (0..300)
8795            .map(|i| {
8796                (
8797                    format!("<http://ex/e{i}>"),
8798                    "<http://ex/next>".to_string(),
8799                    format!("<http://ex/e{}>", (i + 1) % 300),
8800                )
8801            })
8802            .collect();
8803        let mut db = DictionaryBuilder::new();
8804        for (s, p, o) in &triples {
8805            db.observe(s, p, o);
8806        }
8807        let dict = db.build();
8808        let ids: Vec<_> = triples
8809            .iter()
8810            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
8811            .collect();
8812        let mut ib = GraphIndexBuilder::new();
8813        for &t in &ids {
8814            ib.push(t);
8815        }
8816        let (meta, levels) = build_pyramid_meta(&dict, &ids, DEFAULT_TILE_BUDGET);
8817        write_file(&dict, &ib.build(), false, &meta, levels)
8818    }
8819
8820    #[test]
8821    fn ranged_open_is_minimal_and_correct() {
8822        use crate::reader::{CountingReader, SliceReader};
8823        let bytes = big_file_with_pyramid();
8824
8825        let full = CountingReader::new(SliceReader::new(&bytes));
8826        let rete = Rete::open_ranged(&full).unwrap();
8827        // Full open touches at most 4 ranges (header, dict, index, meta).
8828        assert!(full.requests() <= 4, "requests = {}", full.requests());
8829        assert_eq!(
8830            rete.query(Some("<http://ex/e0>"), None, None)[0].2,
8831            "<http://ex/e1>"
8832        );
8833
8834        // Summary-only open skips the index → strictly fewer bytes than the file.
8835        let summ_reader = CountingReader::new(SliceReader::new(&bytes));
8836        let view = SummaryView::open_ranged(&summ_reader).unwrap().unwrap();
8837        assert!(!view.summary.is_empty());
8838        assert!(
8839            summ_reader.bytes_read() < bytes.len() as u64,
8840            "summary read {} of {} bytes",
8841            summ_reader.bytes_read(),
8842            bytes.len()
8843        );
8844        // And fewer than a full open, since it never fetched the index.
8845        assert!(summ_reader.bytes_read() < full.bytes_read());
8846    }
8847
8848    #[test]
8849    fn content_hash_is_set_and_verifies() {
8850        let bytes = build_image();
8851        let rete = Rete::open(&bytes).unwrap();
8852        assert_ne!(
8853            rete.header().content_hash,
8854            [0u8; 16],
8855            "hash must be populated"
8856        );
8857        assert!(verify(&bytes).unwrap(), "freshly built file verifies");
8858        assert!(
8859            verify_ranged(&crate::reader::SliceReader::new(&bytes)).unwrap(),
8860            "the streaming verify agrees"
8861        );
8862
8863        // Same data builds an identical hash (deterministic).
8864        assert_eq!(
8865            Rete::open(&build_image()).unwrap().header().content_hash,
8866            rete.header().content_hash
8867        );
8868
8869        // Corrupting a payload byte breaks verification.
8870        let mut tampered = bytes.clone();
8871        let last = tampered.len() - 5; // inside payload, before footer magic
8872        tampered[last] ^= 0xff;
8873        assert!(!verify(&tampered).unwrap());
8874        assert!(!verify_ranged(&crate::reader::SliceReader::new(&tampered)).unwrap());
8875        // A truncated file is an error, never a pass.
8876        let short = &bytes[..bytes.len() / 2];
8877        assert!(verify_ranged(&crate::reader::SliceReader::new(short)).is_err());
8878        assert!(verify_ranged(&crate::reader::SliceReader::new(&bytes[..10])).is_err());
8879    }
8880
8881    /// Build the standard 3-triple image with an opaque metadata payload.
8882    fn build_with_metadata(meta: &[u8]) -> Vec<u8> {
8883        let triples = [
8884            ("Alice", "knows", "Bob"),
8885            ("Bob", "knows", "Carol"),
8886            ("Alice", "age", "30"),
8887        ];
8888        let mut db = DictionaryBuilder::new();
8889        for (s, p, o) in triples {
8890            db.observe(s, p, o);
8891        }
8892        let dict = db.build();
8893        let ids: Vec<_> = triples
8894            .iter()
8895            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
8896            .collect();
8897        let mut ib = GraphIndexBuilder::new();
8898        for &t in &ids {
8899            ib.push(t);
8900        }
8901        let (pmeta, levels) = build_pyramid_meta(&dict, &ids, DEFAULT_TILE_BUDGET);
8902        write_dataset_with_metadata(&dict, &ib.build(), &[], false, &pmeta, levels, meta, &[])
8903    }
8904
8905    #[test]
8906    fn metadata_round_trips_and_shifts_offsets() {
8907        let card = br#"{"title":"My Dataset"}"#;
8908        let bytes = build_with_metadata(card);
8909        let rete = Rete::open(&bytes).unwrap();
8910
8911        // The opaque payload reads back verbatim.
8912        assert_eq!(rete.metadata(), Some(card.as_slice()));
8913        let h = rete.header();
8914        assert_eq!(h.metadata_offset, HEADER_LEN as u64);
8915        assert_eq!(h.metadata_len, card.len() as u64);
8916        // The dictionary (and everything after it) shifted forward by the card.
8917        assert_eq!(h.dictionary_offset, HEADER_LEN as u64 + card.len() as u64);
8918
8919        // The index still decodes correctly at its shifted offset.
8920        assert_eq!(
8921            rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
8922            1
8923        );
8924        // The card is inside the content hash, so the file still verifies.
8925        assert!(verify(&bytes).unwrap());
8926    }
8927
8928    #[test]
8929    fn empty_metadata_is_byte_identical_to_plain_writer() {
8930        // The `&[]` path must produce exactly the bytes of the metadata-free
8931        // writer for identical inputs — old files and outputs are unchanged.
8932        assert_eq!(
8933            build_with_metadata(&[]),
8934            build_image(),
8935            "empty-metadata output must equal the plain writer byte-for-byte"
8936        );
8937    }
8938
8939    /// The contract `replace_metadata` has to meet to be usable at the end of a
8940    /// build: swapping the card must land on **exactly** the bytes the writer
8941    /// would have produced with that card in the first place — shorter, longer,
8942    /// and gone — so a spliced file and a rebuilt one are the same file.
8943    #[test]
8944    fn replace_metadata_matches_a_direct_write() {
8945        let first = br#"{"title":"My Dataset","queries":[1,2,3]}"#;
8946        let bytes = build_with_metadata(first);
8947        for target in [
8948            br#"{"title":"My Dataset","queries":[1,2]}"#.as_slice(), // shorter
8949            br#"{"title":"My Dataset","queries":[1,2,3,4,5,6,7]}"#.as_slice(), // longer
8950            b"".as_slice(),                                          // removed
8951            first.as_slice(),                                        // unchanged
8952        ] {
8953            let spliced = replace_metadata(&bytes, target).unwrap();
8954            assert_eq!(
8955                spliced,
8956                build_with_metadata(target),
8957                "splicing {} bytes of metadata must equal a direct write",
8958                target.len()
8959            );
8960            assert!(
8961                verify(&spliced).unwrap(),
8962                "the new hash covers the new card"
8963            );
8964            let rete = Rete::open(&spliced).unwrap();
8965            assert_eq!(
8966                rete.metadata(),
8967                (!target.is_empty()).then_some(target),
8968                "the new payload reads back verbatim"
8969            );
8970            // The data is untouched by the surgery.
8971            assert_eq!(
8972                rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
8973                1
8974            );
8975        }
8976    }
8977
8978    /// A build-info section survives the splice, still adjacent to the card, and
8979    /// still outside the content hash.
8980    #[test]
8981    fn replace_metadata_keeps_build_info_adjacent() {
8982        let with_info =
8983            attach_build_info(&build_with_metadata(br#"{"a":1}"#), br#"{"builder":"x"}"#).unwrap();
8984        let spliced = replace_metadata(&with_info, br#"{"a":1,"b":2}"#).unwrap();
8985        let h = Header::from_bytes(&spliced).unwrap();
8986        assert_eq!(h.metadata_offset, HEADER_LEN as u64);
8987        assert_eq!(h.build_info_offset, HEADER_LEN as u64 + h.metadata_len);
8988        assert_eq!(
8989            read_build_info(&spliced).unwrap().as_deref(),
8990            Some(br#"{"builder":"x"}"#.as_slice())
8991        );
8992        assert!(verify(&spliced).unwrap());
8993        // Same card, same content hash, whatever the build-info says.
8994        assert_eq!(
8995            h.content_hash,
8996            Header::from_bytes(&build_with_metadata(br#"{"a":1,"b":2}"#))
8997                .unwrap()
8998                .content_hash
8999        );
9000    }
9001
9002    #[test]
9003    fn metadata_is_tamper_evident() {
9004        let card = br#"{"title":"x"}"#;
9005        let mut bytes = build_with_metadata(card);
9006        assert!(verify(&bytes).unwrap());
9007        // The card occupies [HEADER_LEN .. HEADER_LEN+card_len); flip a byte in it.
9008        bytes[HEADER_LEN + 2] ^= 0xff;
9009        assert!(
9010            !verify(&bytes).unwrap(),
9011            "tampering with the card must break verify()"
9012        );
9013    }
9014
9015    #[test]
9016    fn ranged_opens_do_not_fetch_metadata() {
9017        use crate::reader::{CountingReader, SliceReader};
9018        let card = vec![0xABu8; 512]; // distinctive and sizable
9019        let bytes = build_with_metadata(&card);
9020        let total = bytes.len() as u64;
9021
9022        // A full ranged open never loads the card and never reads its byte range.
9023        let r = CountingReader::new(SliceReader::new(&bytes));
9024        let rete = Rete::open_ranged(&r).unwrap();
9025        assert!(
9026            rete.metadata().is_none(),
9027            "open_ranged must not load the card"
9028        );
9029        assert!(r.requests() <= 4, "requests = {}", r.requests());
9030        assert!(
9031            r.bytes_read() <= total - card.len() as u64,
9032            "read {} of {} bytes; the {}-byte card must be skipped",
9033            r.bytes_read(),
9034            total,
9035            card.len()
9036        );
9037
9038        // Summary-only open likewise ignores the card and still summarizes.
9039        let rs = CountingReader::new(SliceReader::new(&bytes));
9040        let view = SummaryView::open_ranged(&rs).unwrap().unwrap();
9041        assert!(!view.summary.is_empty());
9042        assert!(rs.bytes_read() <= total - card.len() as u64);
9043    }
9044
9045    #[test]
9046    fn metadata_ranged_fetches_only_header_and_card() {
9047        use crate::reader::{CountingReader, SliceReader};
9048        // The CARD tier: fetch the self-description over a RangeReader touching
9049        // only the header + metadata range — never the dictionary/index/pyramid.
9050        let card = vec![0xCDu8; 384];
9051        let bytes = build_with_metadata(&card);
9052
9053        let r = CountingReader::new(SliceReader::new(&bytes));
9054        let got = read_metadata_ranged(&r).unwrap().unwrap();
9055        assert_eq!(got, card, "the card reads back verbatim");
9056        assert_eq!(r.requests(), 2, "exactly header + metadata ranges");
9057        assert_eq!(
9058            r.bytes_read(),
9059            HEADER_LEN as u64 + card.len() as u64,
9060            "no dictionary/index/pyramid bytes are touched"
9061        );
9062
9063        // A cardless file resolves to None after a single header read.
9064        let plain = build_image();
9065        let rp = CountingReader::new(SliceReader::new(&plain));
9066        assert!(read_metadata_ranged(&rp).unwrap().is_none());
9067        assert_eq!(rp.requests(), 1, "header only for a cardless file");
9068        assert_eq!(rp.bytes_read(), HEADER_LEN as u64);
9069    }
9070
9071    #[test]
9072    fn build_info_attaches_outside_the_hash() {
9073        let card = br#"{"title":"My Dataset"}"#;
9074        let base = build_with_metadata(card);
9075        let base_hash = Header::from_bytes(&base).unwrap().content_hash;
9076
9077        let info = br#"{"built_at":"2026-08-04T00:00:00Z","builder":"rete-cli 0.3.2"}"#;
9078        let with = attach_build_info(&base, info).unwrap();
9079
9080        // The section reads back verbatim, adjacent to the metadata.
9081        assert_eq!(read_build_info(&with).unwrap().unwrap(), info);
9082        let h = Header::from_bytes(&with).unwrap();
9083        assert_eq!(h.build_info_offset, HEADER_LEN as u64 + card.len() as u64);
9084        assert_eq!(h.build_info_len, info.len() as u64);
9085        assert_eq!(h.expected_file_len(), Some(with.len() as u64));
9086
9087        // OUTSIDE the hash: the content hash is unchanged and the file still
9088        // verifies — two builds of identical data stay hash-equal even though
9089        // their build-info differs.
9090        assert_eq!(h.content_hash, base_hash);
9091        assert!(verify(&with).unwrap());
9092        let other = attach_build_info(&base, br#"{"built_at":"1999-01-01T00:00:00Z"}"#).unwrap();
9093        assert_eq!(Header::from_bytes(&other).unwrap().content_hash, base_hash);
9094
9095        // The graph still opens and answers at its shifted offsets.
9096        let rete = Rete::open(&with).unwrap();
9097        assert_eq!(
9098            rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
9099            1
9100        );
9101        assert_eq!(rete.metadata(), Some(card.as_slice()));
9102
9103        // Replacing an existing section works and removing it restores the
9104        // original image byte-for-byte (the strip-equality determinism proof).
9105        let replaced = attach_build_info(&with, br#"{"builder":"other"}"#).unwrap();
9106        assert_eq!(
9107            read_build_info(&replaced).unwrap().unwrap(),
9108            br#"{"builder":"other"}"#
9109        );
9110        assert!(verify(&replaced).unwrap());
9111        let stripped = attach_build_info(&replaced, &[]).unwrap();
9112        assert_eq!(stripped, base, "stripping build-info restores the image");
9113    }
9114
9115    #[test]
9116    fn build_info_attaches_on_a_metadata_free_file() {
9117        // No card: the section slots in right after the header.
9118        let base = build_image();
9119        let info = b"{\"builder\":\"x\"}";
9120        let with = attach_build_info(&base, info).unwrap();
9121        let h = Header::from_bytes(&with).unwrap();
9122        assert_eq!(h.build_info_offset, HEADER_LEN as u64);
9123        assert_eq!(h.metadata_len, 0);
9124        assert_eq!(read_build_info(&with).unwrap().unwrap(), info);
9125        assert!(verify(&with).unwrap());
9126        assert_eq!(
9127            h.content_hash,
9128            Header::from_bytes(&base).unwrap().content_hash
9129        );
9130        Rete::open(&with).unwrap();
9131    }
9132
9133    /// The contract a **streaming** rewriter needs: a plan is enough to rebuild
9134    /// the file from three spans — the header it hands back, the untouched
9135    /// `[HEADER_LEN, insert)` prefix, the new section, and the untouched
9136    /// `[tail_start, len)` tail — and the result is byte-identical to the
9137    /// in-memory splice. That is what lets a 17 GB file gain a build record
9138    /// with a 4 MiB buffer instead of 34 GB of RAM.
9139    #[test]
9140    fn a_plan_rebuilds_exactly_what_the_in_memory_splice_produces() {
9141        let card = br#"{"title":"My Dataset"}"#;
9142        let base = build_with_metadata(card);
9143        let base_hash = Header::from_bytes(&base).unwrap().content_hash;
9144        for start in [
9145            base.clone(),
9146            attach_build_info(&base, b"{\"a\":1}").unwrap(),
9147        ] {
9148            for info in [
9149                br#"{"builder":"rete-cli 0.3.2","query_costs":{}}"#.as_slice(), // longer
9150                b"{}".as_slice(),                                               // shorter
9151                b"".as_slice(),                                                 // removed
9152            ] {
9153                let plan = plan_build_info(&start, start.len() as u64, info.len() as u64).unwrap();
9154                let mut streamed = Vec::new();
9155                streamed.extend_from_slice(&plan.header);
9156                streamed.extend_from_slice(&start[HEADER_LEN..plan.insert as usize]);
9157                streamed.extend_from_slice(info);
9158                streamed.extend_from_slice(&start[plan.tail_start as usize..]);
9159                assert_eq!(streamed.len() as u64, plan.new_len);
9160                assert_eq!(
9161                    streamed,
9162                    attach_build_info(&start, info).unwrap(),
9163                    "a {}-byte section streamed must equal the same section spliced",
9164                    info.len()
9165                );
9166                // The point of the exercise: the file's identity is untouched.
9167                assert!(verify(&streamed).unwrap());
9168                let h = Header::from_bytes(&streamed).unwrap();
9169                assert_eq!(h.content_hash, base_hash);
9170                assert_eq!(h.expected_file_len(), Some(plan.new_len));
9171                let rete = Rete::open(&streamed).unwrap();
9172                assert_eq!(rete.metadata(), Some(card.as_slice()));
9173                assert_eq!(
9174                    rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
9175                    1
9176                );
9177            }
9178        }
9179    }
9180
9181    /// A plan is refused rather than trusted when the header's own numbers put
9182    /// the section outside the file — the header is attacker-controlled input.
9183    #[test]
9184    fn a_plan_refuses_a_header_that_overruns_the_file() {
9185        let base = build_with_metadata(br#"{"title":"x"}"#);
9186        assert!(plan_build_info(&base, 8, 16).is_err());
9187        assert!(plan_build_info(&base[..HEADER_LEN], base.len() as u64, 16).is_ok());
9188    }
9189
9190    #[test]
9191    fn card_and_build_info_ranged_is_one_header_plus_one_range() {
9192        use crate::reader::{CountingReader, SliceReader};
9193        let card = vec![0xCDu8; 384];
9194        let info = vec![0xEFu8; 200];
9195        let bytes = attach_build_info(&build_with_metadata(&card), &info).unwrap();
9196
9197        // Both sections in ONE coalesced range after the header — the CARD
9198        // tier's 1 header + 1 range budget holds with build info included.
9199        let r = CountingReader::new(SliceReader::new(&bytes));
9200        let (m, b) = read_card_and_build_info_ranged(&r).unwrap();
9201        assert_eq!(m.unwrap(), card);
9202        assert_eq!(b.unwrap(), info);
9203        assert_eq!(r.requests(), 2, "header + one coalesced range");
9204        assert_eq!(
9205            r.bytes_read(),
9206            HEADER_LEN as u64 + card.len() as u64 + info.len() as u64
9207        );
9208
9209        // Card only: same two requests, no build info.
9210        let plain = build_with_metadata(&card);
9211        let rp = CountingReader::new(SliceReader::new(&plain));
9212        let (m, b) = read_card_and_build_info_ranged(&rp).unwrap();
9213        assert_eq!(m.unwrap(), card);
9214        assert!(b.is_none());
9215        assert_eq!(rp.requests(), 2);
9216        // Stated as the comparison every client depends on: carrying a build
9217        // record costs a reader NOTHING in requests. Any client that shows the
9218        // build record — the CLI, the browser card modal — can therefore do it
9219        // in the card's own budget, and does not need a second call.
9220        assert_eq!(
9221            r.requests(),
9222            rp.requests(),
9223            "build info must not cost an extra request",
9224        );
9225
9226        // Neither: the header read alone answers.
9227        let none = build_image();
9228        let rn = CountingReader::new(SliceReader::new(&none));
9229        let (m, b) = read_card_and_build_info_ranged(&rn).unwrap();
9230        assert!(m.is_none() && b.is_none());
9231        assert_eq!(rn.requests(), 1);
9232    }
9233
9234    #[test]
9235    fn schema_summary_groups_by_type() {
9236        let rt = RDF_TYPE;
9237        let bytes = build_from(&[
9238            ("Alice", rt, "Person"),
9239            ("Bob", rt, "Person"),
9240            ("NYC", rt, "City"),
9241            ("Alice", "knows", "Bob"),
9242            ("Alice", "livesIn", "NYC"),
9243            ("Alice", "name", "\"Alice\""),
9244        ]);
9245        let rete = Rete::open(&bytes).unwrap();
9246        let summary = schema_summary(&rete);
9247        // Expect class-level relations, rdf:type excluded.
9248        assert!(summary.contains(&("Person".into(), "knows".into(), "Person".into(), 1)));
9249        assert!(summary.contains(&("Person".into(), "livesIn".into(), "City".into(), 1)));
9250        assert!(summary.contains(&("Person".into(), "name".into(), "(literal)".into(), 1)));
9251        // No rdf:type relations in the summary.
9252        assert!(!summary.iter().any(|(_, p, _, _)| p == RDF_TYPE));
9253
9254        // Class populations: 2 People, 1 City, sorted by count desc.
9255        let classes = schema_classes(&rete);
9256        assert_eq!(
9257            classes,
9258            vec![("Person".into(), 2u32), ("City".into(), 1u32)]
9259        );
9260    }
9261
9262    fn build_from(triples: &[(&str, &str, &str)]) -> Vec<u8> {
9263        let mut db = DictionaryBuilder::new();
9264        for (s, p, o) in triples {
9265            db.observe(s, p, o);
9266        }
9267        let dict = db.build();
9268        let mut ib = GraphIndexBuilder::new();
9269        for (s, p, o) in triples {
9270            ib.push(dict.encode(s, p, o).unwrap());
9271        }
9272        write_file(&dict, &ib.build(), false, &[], 0)
9273    }
9274
9275    /// Build a file WITH a pyramid (so the schema pyramid + coherence axioms ship).
9276    fn build_with_pyramid(triples: &[(&str, &str, &str)]) -> Vec<u8> {
9277        let mut db = DictionaryBuilder::new();
9278        for (s, p, o) in triples {
9279            db.observe(s, p, o);
9280        }
9281        let dict = db.build();
9282        let encoded: Vec<_> = triples
9283            .iter()
9284            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
9285            .collect();
9286        let mut ib = GraphIndexBuilder::new();
9287        for t in &encoded {
9288            ib.push(*t);
9289        }
9290        let (meta, levels) = build_pyramid_meta(&dict, &encoded, DEFAULT_TILE_BUDGET);
9291        write_dataset(&dict, &ib.build(), &[], false, &meta, levels)
9292    }
9293
9294    #[test]
9295    fn tbox_coherence_flags_unsatisfiable_class_index_free() {
9296        use crate::reader::{CountingReader, SliceReader};
9297        let rt = RDF_TYPE;
9298        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
9299        let disj = "<http://www.w3.org/2002/07/owl#disjointWith>";
9300        // C ⊑ D, C ⊑ E, D disjointWith E ⇒ C unsatisfiable — schema-only, with one
9301        // instance present just so the schema pyramid gets built.
9302        let bytes = build_with_pyramid(&[
9303            ("<http://ex/C>", sub, "<http://ex/D>"),
9304            ("<http://ex/C>", sub, "<http://ex/E>"),
9305            ("<http://ex/D>", disj, "<http://ex/E>"),
9306            ("<http://ex/x>", rt, "<http://ex/C>"),
9307        ]);
9308
9309        let r = CountingReader::new(SliceReader::new(&bytes));
9310        let view = SummaryView::open_ranged(&r).unwrap().unwrap();
9311        let points = view.tbox_coherence();
9312        assert!(
9313            points
9314                .iter()
9315                .any(|i| i.kind == "unsatisfiable-class" && i.detail.contains("http://ex/C>")),
9316            "expected C unsatisfiable from the schema alone, got {points:?}"
9317        );
9318
9319        // Proven index-free: bytes read never reach the (root_dir) index section,
9320        // mirroring `schema_pyramid_round_trips_through_file_index_free`.
9321        let header = Header::from_bytes(&bytes[..HEADER_LEN]).unwrap();
9322        assert!(
9323            r.bytes_read() <= bytes.len() as u64 - header.root_dir_len,
9324            "tbox_coherence must not read the triple index"
9325        );
9326    }
9327
9328    #[test]
9329    fn schema_coherence_reads_only_the_schema_block() {
9330        use crate::reader::{CountingReader, SliceReader};
9331        let rt = RDF_TYPE;
9332        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
9333        let disj = "<http://www.w3.org/2002/07/owl#disjointWith>";
9334        // 500 instances with unique literals → a sizable dictionary + community
9335        // summary, so a whole-pyramid-meta read would be large; the schema block
9336        // (bounded by the tiny ontology) stays small.
9337        let mut triples: Vec<(String, String, String)> = vec![
9338            ("<http://ex/C>".into(), sub.into(), "<http://ex/D>".into()),
9339            ("<http://ex/C>".into(), sub.into(), "<http://ex/E>".into()),
9340            ("<http://ex/D>".into(), disj.into(), "<http://ex/E>".into()),
9341        ];
9342        for i in 0..500 {
9343            let s = format!("<http://ex/x{i}>");
9344            triples.push((s.clone(), rt.into(), "<http://ex/C>".into()));
9345            triples.push((
9346                s,
9347                "<http://ex/label>".into(),
9348                format!("\"unique label {i}\""),
9349            ));
9350        }
9351        let trefs: Vec<(&str, &str, &str)> = triples
9352            .iter()
9353            .map(|(s, p, o)| (s.as_str(), p.as_str(), o.as_str()))
9354            .collect();
9355        let bytes = build_with_pyramid(&trefs);
9356
9357        let header = Header::from_bytes(&bytes[..HEADER_LEN]).unwrap();
9358        assert!(
9359            header.schema_meta_len > 0,
9360            "the writer recorded a schema-block length"
9361        );
9362        assert!(
9363            (header.schema_meta_len as u64) < header.pyramid_meta_len,
9364            "schema block ({}) should be far smaller than the whole pyramid-meta ({})",
9365            header.schema_meta_len,
9366            header.pyramid_meta_len
9367        );
9368
9369        let r = CountingReader::new(SliceReader::new(&bytes));
9370        let points = read_schema_coherence_ranged(&r).unwrap().unwrap();
9371        assert!(points.iter().any(|i| i.kind == "unsatisfiable-class"));
9372        // It read only the header + the schema block — not the summary or dictionary.
9373        assert!(
9374            r.bytes_read() <= HEADER_LEN as u64 + header.schema_meta_len as u64,
9375            "read {} bytes; expected <= header + schema block ({})",
9376            r.bytes_read(),
9377            HEADER_LEN as u64 + header.schema_meta_len as u64
9378        );
9379    }
9380
9381    #[test]
9382    fn tbox_coherence_clean_schema_is_coherent() {
9383        use crate::reader::SliceReader;
9384        let rt = RDF_TYPE;
9385        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
9386        let bytes = build_with_pyramid(&[
9387            ("<http://ex/Dog>", sub, "<http://ex/Animal>"),
9388            ("<http://ex/x>", rt, "<http://ex/Dog>"),
9389        ]);
9390        let view = SummaryView::open_ranged(&SliceReader::new(&bytes))
9391            .unwrap()
9392            .unwrap();
9393        assert!(view.tbox_is_coherent(), "a plain hierarchy is coherent");
9394    }
9395
9396    /// A quad-index file must be **refused** by every reader that predates it —
9397    /// an old reader ignores flag bit 3 and the unknown kind 10, so it would
9398    /// otherwise open the file as one without named graphs and answer `GRAPH`
9399    /// queries with nothing. The seal makes each of their open paths fail on the
9400    /// index container's section count, before any payload is read; their
9401    /// `verify` still passes, because the part list it hashes is unchanged.
9402    /// (Checked against real 0.3.x binaries too: QUAD-INDEX-DESIGN.md §9.)
9403    #[test]
9404    fn readers_before_the_quad_index_refuse_its_files() {
9405        for perms in [PermSet::ALL, PermSet::CORE] {
9406            let quads: Vec<crate::ingest::RawQuad> = (0..50)
9407                .map(|i| {
9408                    (
9409                        format!("<http://ex/s{i}>"),
9410                        "<http://ex/p>".to_string(),
9411                        format!("\"{i}\""),
9412                        Some(format!("<http://ex/g{}>", i % 7)),
9413                    )
9414                })
9415                .collect();
9416            let (bytes, _) = crate::ingest::assemble_dataset_with_layout(
9417                quads,
9418                false,
9419                false,
9420                None,
9421                crate::PyramidAlgo::Louvain,
9422                perms,
9423                crate::ingest::GraphLayout::Quad,
9424                |_, _| Vec::<u8>::new(),
9425            );
9426            let h = Header::from_bytes(&bytes).unwrap();
9427            assert!(h.has_quad_index());
9428            let root =
9429                &bytes[h.root_dir_offset as usize..(h.root_dir_offset + h.root_dir_len) as usize];
9430            // 0.3.4–0.3.7, resident (`Rete::open`, `open_ranged`): count != mask.
9431            assert!(decode_index_container(root, h.block_codec, h.perms).is_err());
9432            assert!(
9433                container_section_payload_ranges(root, h.root_dir_offset, h.perms.len()).is_err()
9434            );
9435            // 0.3.0–0.3.3 expected exactly six sections, whatever the mask.
9436            assert_ne!(decode_container(root, CODEC_NONE).unwrap().len(), NUM_PERMS);
9437            assert_ne!(
9438                decode_container(root, CODEC_NONE).unwrap().len(),
9439                h.perms.len()
9440            );
9441            // Every lazy / remote open (`open_ranged_lazy`, `query_ranged`).
9442            let r = crate::reader::SliceReader::new(&bytes);
9443            for expect in [h.perms.len(), NUM_PERMS] {
9444                assert!(locate_container_section_ranged(
9445                    &r,
9446                    h.root_dir_offset,
9447                    h.root_dir_len,
9448                    0,
9449                    expect as u64
9450                )
9451                .is_err());
9452            }
9453            // Their `verify` hashes the same parts the writer hashed.
9454            assert_eq!(hash_of_sections(&h, &bytes).unwrap(), h.content_hash);
9455            // And a current reader reads the file.
9456            let rete = Rete::open(&bytes).unwrap();
9457            assert_eq!(rete.named_graph_count(), 7);
9458            assert_eq!(rete.dump(Some("<http://ex/g3>")).len(), 7);
9459        }
9460    }
9461
9462    #[test]
9463    fn named_graphs_round_trip() {
9464        // One shared dictionary; default graph + a named graph "g1".
9465        let all = [
9466            ("Alice", "knows", "Bob"), // default
9467            ("Bob", "age", "30"),      // named g1
9468        ];
9469        let mut db = DictionaryBuilder::new();
9470        for (s, p, o) in all {
9471            db.observe(s, p, o);
9472        }
9473        let dict = db.build();
9474
9475        let mut def = GraphIndexBuilder::new();
9476        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
9477        let mut g1 = GraphIndexBuilder::new();
9478        g1.push(dict.encode("Bob", "age", "30").unwrap());
9479
9480        let named = vec![("http://ex/g1".to_string(), g1.build())];
9481        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
9482
9483        assert!(verify(&bytes).unwrap());
9484        let rete = Rete::open(&bytes).unwrap();
9485        assert_eq!(rete.graph_names(), vec!["http://ex/g1"]);
9486        // The named graph contains Bob age 30, not the default-graph triple.
9487        let gi = rete.graph_index("http://ex/g1").unwrap();
9488        assert_eq!(gi.triple_count(), 1);
9489        assert!(rete.graph_index("http://ex/missing").is_none());
9490
9491        // quad_count counts ALL quads — default graph + named graphs (1 + 1),
9492        // not just the default index (which would report 1).
9493        assert_eq!(rete.header().quad_count, 2);
9494
9495        // Default-graph query path is unchanged.
9496        assert_eq!(rete.query(Some("Alice"), None, None).len(), 1);
9497
9498        // dump() round-trips each graph back to terms.
9499        assert_eq!(
9500            rete.dump(None),
9501            vec![("Alice".into(), "knows".into(), "Bob".into())]
9502        );
9503        assert_eq!(
9504            rete.dump(Some("http://ex/g1")),
9505            vec![("Bob".into(), "age".into(), "30".into())]
9506        );
9507    }
9508
9509    /// A named graph whose container exceeds [`NAMED_GRAPH_RESIDENT_MAX`]
9510    /// opens through [`open_index_container_lazy`] — the default graph's own
9511    /// machinery, at the container's offset. Drive that helper directly over a
9512    /// named graph's container range (the threshold itself is too large to
9513    /// cross cheaply in a unit test) and require identical matches.
9514    #[test]
9515    fn named_graph_container_opens_tile_lazily_like_the_root() {
9516        let mut db = DictionaryBuilder::new();
9517        let node = |n: u32| format!("<http://ex/n{n}>");
9518        let p = "<http://ex/p>".to_string();
9519        db.observe(&node(0), &p, &node(1));
9520        for i in 0..500u32 {
9521            db.observe(&node(1000 + i), &p, &node(2000 + i));
9522        }
9523        let dict = db.build();
9524        let mut def = GraphIndexBuilder::new();
9525        def.push(dict.encode(&node(0), &p, &node(1)).unwrap());
9526        let mut g = GraphIndexBuilder::new().with_tile_budget(256); // many tiles
9527        for i in 0..500u32 {
9528            g.push(dict.encode(&node(1000 + i), &p, &node(2000 + i)).unwrap());
9529        }
9530        let named = vec![("<http://ex/g>".to_string(), g.build())];
9531        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
9532        let rete = Rete::open(&bytes).unwrap();
9533        let header = rete.header();
9534
9535        // The single entry's container range: count varint, iri_len varint,
9536        // iri, container_len varint, then the container.
9537        let soff = header.named_graphs_offset as usize;
9538        let send = soff + header.named_graphs_len as usize;
9539        let sec = &bytes[soff..send];
9540        let (n, mut pos) = read_uvarint(sec).unwrap();
9541        assert_eq!(n, 1);
9542        let (ilen, u1) = read_uvarint(&sec[pos..]).unwrap();
9543        pos += u1 + ilen as usize;
9544        let (clen, u2) = read_uvarint(&sec[pos..]).unwrap();
9545        pos += u2;
9546        let container = ByteRange {
9547            offset: (soff + pos) as u64,
9548            len: clen,
9549        };
9550
9551        use crate::reader::SliceReader;
9552        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
9553        let reader: std::sync::Arc<dyn RangeReader + Send + Sync> =
9554            std::sync::Arc::new(SliceReader::new(leaked));
9555        let (lazy_idx, _, _) = open_index_container_lazy(
9556            &reader,
9557            container,
9558            header.block_codec,
9559            header.has_tile_synopsis(),
9560            1,
9561            header.perms,
9562            header.perms.len(),
9563        )
9564        .unwrap();
9565        let mut want = rete
9566            .graph_index("<http://ex/g>")
9567            .unwrap()
9568            .match_pattern((None, None, None));
9569        let mut got = lazy_idx.match_pattern((None, None, None));
9570        want.sort_unstable();
9571        got.sort_unstable();
9572        assert_eq!(got.len(), 500);
9573        assert_eq!(got, want);
9574        assert!(!lazy_idx.load_incomplete());
9575    }
9576
9577    /// `dump_iter` must agree with `dump` exactly — and must be *lazy*: taking
9578    /// the first triple of a lazily range-read file may not drag the whole
9579    /// index across the reader, or the JS client's streaming quad cursor (which
9580    /// is this iterator, suspended between wasm calls) would be a materializing
9581    /// dump wearing a cursor's clothes.
9582    #[test]
9583    fn dump_iter_matches_dump_and_stops_early() {
9584        use crate::reader::{CountingReader, SliceReader};
9585
9586        // Enough triples over tiny tiles to force many tiles per permutation.
9587        let triples: Vec<(String, String, String)> = (0..2000u32)
9588            .map(|i| {
9589                (
9590                    format!("<http://ex/s/{i:04}>"),
9591                    "<http://ex/p>".to_string(),
9592                    format!("<http://ex/o/{i:04}>"),
9593                )
9594            })
9595            .collect();
9596        let mut db = DictionaryBuilder::new();
9597        for (s, p, o) in &triples {
9598            db.observe(s, p, o);
9599        }
9600        let dict = db.build();
9601        let mut ib = GraphIndexBuilder::new().with_tile_budget(256);
9602        for (s, p, o) in &triples {
9603            ib.push(dict.encode(s, p, o).unwrap());
9604        }
9605        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
9606
9607        // Same triples, same order, as the eager dump.
9608        let rete = Rete::open(&bytes).unwrap();
9609        assert_eq!(rete.dump_iter(None).collect::<Vec<_>>(), rete.dump(None));
9610        // A missing named graph yields nothing rather than the default graph.
9611        assert_eq!(rete.dump_iter(Some("http://ex/nope")).count(), 0);
9612
9613        // Lazily: taking one triple must cost far fewer bytes than draining.
9614        let read_bytes = |take: usize| -> u64 {
9615            let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
9616            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
9617            let rete = Rete::open_ranged_lazy_with(reader.clone(), HEADER_LEN as u64).unwrap();
9618            let before = reader.bytes_read();
9619            let n = rete.dump_iter(None).take(take).count();
9620            assert_eq!(n, take.min(triples.len()));
9621            assert!(!rete.index_incomplete());
9622            reader.bytes_read() - before
9623        };
9624        let first_only = read_bytes(1);
9625        let everything = read_bytes(triples.len());
9626        // Always true: stopping after one triple cannot cost a full scan.
9627        assert!(
9628            first_only < everything,
9629            "dump_iter is not lazy: one triple read {first_only} B, a full scan {everything} B"
9630        );
9631        // The STRONG claim — under half — only holds once the payload dwarfs the
9632        // fixed open cost. This fixture is ~32 KB, and with `compression` off the
9633        // dictionary is essentially the whole file, so opening it at all already
9634        // pays most of `everything` (21 KB of 33 KB) and the ratio says nothing
9635        // about laziness. Asserting it unconditionally is how this test failed on
9636        // `--no-default-features` while passing everywhere else.
9637        #[cfg(feature = "compression")]
9638        assert!(
9639            first_only * 2 < everything,
9640            "dump_iter is not lazy: one triple read {first_only} B of the {everything} B a full scan reads"
9641        );
9642
9643        // A FRESH open: these assertions fault tiles and the dictionary in,
9644        // and the laziness measurement above counts bytes on a COLD reader —
9645        // sharing one `rete` makes first_only and everything both ~0, so the
9646        // comparison silently stops testing anything.
9647        let fresh = Rete::open(&bytes).unwrap();
9648        // `dump_batch` is the resumable form the language clients pull through.
9649        // Driven to exhaustion it must reproduce the eager dump EXACTLY — same
9650        // triples, same order — for any batch size, including one so small that
9651        // every call ends on a subject boundary before reaching it.
9652        let drain = |max_quads: usize| {
9653            let (mut out, mut cursor, mut calls) = (Vec::new(), 0u32, 0);
9654            loop {
9655                let (batch, next, done) = fresh.dump_batch(None, cursor, max_quads);
9656                out.extend(batch);
9657                cursor = next;
9658                calls += 1;
9659                assert!(calls < 10_000, "cursor failed to advance");
9660                if done {
9661                    break out;
9662                }
9663            }
9664        };
9665        let eager = fresh.dump(None);
9666        for max in [1usize, 7, 128, 100_000] {
9667            assert_eq!(drain(max), eager, "batch size {max} changed the dump");
9668        }
9669        // An unknown graph is an empty, already-finished dump — not an error and
9670        // not the default graph leaking through.
9671        let (t, _, done) = fresh.dump_batch(Some("http://ex/nope"), 0, 16);
9672        assert!(t.is_empty() && done);
9673    }
9674
9675    /// `query_batch` is `dump_batch` generalized to a PATTERN — the primitive the
9676    /// Java client's RDF4J cursor pulls through, one bounded batch per wasm call.
9677    /// Three things have to hold or the cursor is a materializing scan wearing a
9678    /// cursor's clothes: driven to exhaustion it must reproduce the eager
9679    /// `query_in_graph` for every pattern shape and every batch size; taking one
9680    /// row must cost far fewer bytes than draining; and every call must either
9681    /// yield a row or say `done`, so the caller can never spin.
9682    #[test]
9683    fn query_batch_matches_query_in_graph_and_stops_early() {
9684        use crate::reader::{CountingReader, SliceReader};
9685
9686        // Enough triples over tiny tiles that a scan spans many tiles, with two
9687        // predicates and a repeated subject so bound patterns have real fanout.
9688        let triples: Vec<(String, String, String)> = (0..2000u32)
9689            .map(|i| {
9690                (
9691                    format!("<http://ex/s/{:04}>", i / 2),
9692                    format!("<http://ex/p/{}>", i % 2),
9693                    format!("<http://ex/o/{i:04}>"),
9694                )
9695            })
9696            .collect();
9697        let mut db = DictionaryBuilder::new();
9698        for (s, p, o) in &triples {
9699            db.observe(s, p, o);
9700        }
9701        let dict = db.build();
9702        let mut ib = GraphIndexBuilder::new().with_tile_budget(256);
9703        for (s, p, o) in &triples {
9704            ib.push(dict.encode(s, p, o).unwrap());
9705        }
9706        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
9707        let rete = Rete::open(&bytes).unwrap();
9708
9709        let drain = |s: Option<&str>, p: Option<&str>, o: Option<&str>, max: usize| {
9710            let (mut out, mut cursor, mut calls) = (Vec::new(), 0u64, 0);
9711            loop {
9712                let (batch, next, done) = rete.query_batch(None, s, p, o, cursor, max);
9713                // The contract the client relies on: rows, or done. Never
9714                // "nothing yet, call again" — that is an unbounded spin.
9715                assert!(!batch.is_empty() || done, "empty non-final batch");
9716                out.extend(batch);
9717                cursor = next;
9718                calls += 1;
9719                assert!(calls < 20_000, "cursor failed to advance");
9720                if done {
9721                    break out;
9722                }
9723            }
9724        };
9725        let shapes: [(Option<&str>, Option<&str>, Option<&str>); 6] = [
9726            (None, None, None),
9727            (Some("<http://ex/s/0007>"), None, None),
9728            (None, Some("<http://ex/p/1>"), None),
9729            (None, None, Some("<http://ex/o/0042>")),
9730            (Some("<http://ex/s/0007>"), Some("<http://ex/p/0>"), None),
9731            (
9732                Some("<http://ex/s/0007>"),
9733                Some("<http://ex/p/0>"),
9734                Some("<http://ex/o/0014>"),
9735            ),
9736        ];
9737        for (s, p, o) in shapes {
9738            let eager = rete.query_in_graph(None, s, p, o);
9739            assert!(
9740                !eager.is_empty(),
9741                "fixture shape {s:?} {p:?} {o:?} matches nothing"
9742            );
9743            // The lazy pull form agrees exactly, order included.
9744            assert_eq!(
9745                rete.query_iter(None, s, p, o).collect::<Vec<_>>(),
9746                eager,
9747                "query_iter disagrees for {s:?} {p:?} {o:?}"
9748            );
9749            for max in [1usize, 3, 64, 100_000] {
9750                let mut got = drain(s, p, o, max);
9751                let mut want = eager.clone();
9752                // A batch is re-sorted canonically per batch, not globally, so
9753                // a bound pattern's batches can arrive in permutation order.
9754                got.sort();
9755                want.sort();
9756                assert_eq!(got, want, "batch size {max} changed {s:?} {p:?} {o:?}");
9757            }
9758            // The fully unbound scan routes to SPO, so batching is order-exact.
9759            if s.is_none() && p.is_none() && o.is_none() {
9760                assert_eq!(drain(s, p, o, 7), eager, "unbound batching reordered rows");
9761            }
9762        }
9763
9764        // A bound term the dictionary has never seen, and an unknown graph, are
9765        // both finished-and-empty rather than errors or a leak of everything.
9766        let (t, _, done) = rete.query_batch(None, Some("<http://ex/nope>"), None, None, 0, 16);
9767        assert!(t.is_empty() && done);
9768        let (t, _, done) = rete.query_batch(Some("http://ex/nope"), None, None, None, 0, 16);
9769        assert!(t.is_empty() && done);
9770        assert_eq!(
9771            rete.query_iter(None, Some("<http://ex/nope>"), None, None)
9772                .count(),
9773            0
9774        );
9775
9776        // Lazily: one row must not drag the whole scan across the reader. This
9777        // is the property that makes `LIMIT 1` bounded on a 48 GiB file.
9778        let read_bytes = |take: usize| -> u64 {
9779            let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
9780            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
9781            let rete = Rete::open_ranged_lazy_with(reader.clone(), HEADER_LEN as u64).unwrap();
9782            let before = reader.bytes_read();
9783            let (mut n, mut cursor) = (0usize, 0u64);
9784            loop {
9785                let (batch, next, done) = rete.query_batch(None, None, None, None, cursor, 1);
9786                n += batch.len();
9787                cursor = next;
9788                if done || n >= take {
9789                    break;
9790                }
9791            }
9792            assert!(n >= take.min(triples.len()));
9793            assert!(!rete.index_incomplete());
9794            reader.bytes_read() - before
9795        };
9796        let first_only = read_bytes(1);
9797        let everything = read_bytes(triples.len());
9798        assert!(
9799            first_only < everything,
9800            "query_batch is not lazy: one row read {first_only} B, a full scan {everything} B"
9801        );
9802        #[cfg(feature = "compression")]
9803        assert!(
9804            first_only * 2 < everything,
9805            "query_batch is not lazy: one row read {first_only} B of the {everything} B a full scan reads"
9806        );
9807    }
9808
9809    /// A batched cursor over a QUADS file must stay inside the graph it was
9810    /// given: the default graph and each named graph resume independently.
9811    #[test]
9812    fn query_batch_is_graph_scoped() {
9813        let mut db = DictionaryBuilder::new();
9814        for (s, p, o) in [
9815            ("Alice", "knows", "Bob"),
9816            ("Alice", "knows", "Carol"),
9817            ("Alice", "knows", "Dave"),
9818            ("Erin", "knows", "Frank"),
9819            ("Alice", "knows", "Zoe"),
9820        ] {
9821            db.observe(s, p, o);
9822        }
9823        let dict = db.build();
9824        let mut def = GraphIndexBuilder::new();
9825        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
9826        def.push(dict.encode("Alice", "knows", "Carol").unwrap());
9827        let mut g1 = GraphIndexBuilder::new();
9828        g1.push(dict.encode("Alice", "knows", "Dave").unwrap());
9829        g1.push(dict.encode("Erin", "knows", "Frank").unwrap());
9830        let mut g2 = GraphIndexBuilder::new();
9831        g2.push(dict.encode("Alice", "knows", "Zoe").unwrap());
9832        let named = vec![
9833            ("http://ex/g1".to_string(), g1.build()),
9834            ("http://ex/g2".to_string(), g2.build()),
9835        ];
9836        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
9837        let rete = Rete::open(&bytes).unwrap();
9838        for graph in [None, Some("http://ex/g1"), Some("http://ex/g2")] {
9839            let eager = rete.query_in_graph(graph, None, None, None);
9840            let (mut out, mut cursor) = (Vec::new(), 0u64);
9841            loop {
9842                let (batch, next, done) = rete.query_batch(graph, None, None, None, cursor, 1);
9843                out.extend(batch);
9844                cursor = next;
9845                if done {
9846                    break;
9847                }
9848            }
9849            assert_eq!(out, eager, "graph {graph:?} batched differently");
9850        }
9851    }
9852
9853    #[test]
9854    fn query_in_graph_is_graph_scoped() {
9855        // Default graph: Alice knows Bob, Alice knows Carol.
9856        // Named g1: Alice knows Dave (same predicate, different graph).
9857        let mut db = DictionaryBuilder::new();
9858        for (s, p, o) in [
9859            ("Alice", "knows", "Bob"),
9860            ("Alice", "knows", "Carol"),
9861            ("Alice", "knows", "Dave"),
9862        ] {
9863            db.observe(s, p, o);
9864        }
9865        let dict = db.build();
9866
9867        let mut def = GraphIndexBuilder::new();
9868        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
9869        def.push(dict.encode("Alice", "knows", "Carol").unwrap());
9870        let mut g1 = GraphIndexBuilder::new();
9871        g1.push(dict.encode("Alice", "knows", "Dave").unwrap());
9872
9873        let named = vec![("http://ex/g1".to_string(), g1.build())];
9874        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
9875        let rete = Rete::open(&bytes).unwrap();
9876
9877        // Default graph only: the two default-graph objects, not Dave.
9878        let mut def_objs: Vec<String> = rete
9879            .query_in_graph(None, Some("Alice"), Some("knows"), None)
9880            .into_iter()
9881            .map(|(_, _, o)| o)
9882            .collect();
9883        def_objs.sort();
9884        assert_eq!(def_objs, vec!["Bob".to_string(), "Carol".to_string()]);
9885
9886        // Named graph only: just Dave.
9887        assert_eq!(
9888            rete.query_in_graph(Some("http://ex/g1"), Some("Alice"), None, None),
9889            vec![("Alice".into(), "knows".into(), "Dave".into())]
9890        );
9891
9892        // A wildcard-everything scan is scoped to its graph.
9893        assert_eq!(rete.query_in_graph(None, None, None, None).len(), 2);
9894        assert_eq!(
9895            rete.query_in_graph(Some("http://ex/g1"), None, None, None)
9896                .len(),
9897            1
9898        );
9899
9900        // An unknown graph IRI is empty, not an error.
9901        assert!(rete
9902            .query_in_graph(Some("http://ex/missing"), None, None, None)
9903            .is_empty());
9904    }
9905
9906    /// A **bounded** `query_in_graph` — and a dump of one named graph — must not
9907    /// fault the whole dictionary on a lazily range-read file.
9908    ///
9909    /// Both used to call `Dictionary::prefetch_all`, so the graph-scoped pattern
9910    /// primitive an RDF4J `Sail` calls once per named graph paid for every term
9911    /// in the file to answer a handful of rows. Measured on `cordis.rete`
9912    /// (801 MB, a 417 MB dictionary) before the fix: a 15-row answer read
9913    /// 416 MB and peaked at 2.5 GB RSS. The fixture below is the same shape in
9914    /// miniature — long literals so the dictionary dominates the file — and the
9915    /// assertion is the invariant that regressed: bytes read must stay well
9916    /// under the dictionary section. Lazy answers are compared against eager
9917    /// throughout: cheaper is only interesting if it is also correct.
9918    #[test]
9919    fn bounded_query_in_graph_does_not_fault_the_whole_dictionary() {
9920        use crate::reader::{CountingReader, SliceReader};
9921
9922        // Long, distinct, poorly-compressible object literals: the dictionary is
9923        // the payload here, exactly as on a graph that stores abstracts or
9924        // embedded media. Repeating one filler string would not do — front
9925        // coding plus zstd shrink 2000 near-identical literals to ~6 KB, which
9926        // is smaller than the index and makes the measurement meaningless.
9927        let payload = |i: u32| -> String {
9928            let mut s = String::with_capacity(800);
9929            let mut x = i as u64 ^ 0x9e37_79b9_7f4a_7c15;
9930            while s.len() < 800 {
9931                x = x
9932                    .wrapping_mul(6364136223846793005)
9933                    .wrapping_add(1442695040888963407);
9934                s.push_str(&format!("{:016x}", x ^ (x >> 29)));
9935            }
9936            s
9937        };
9938        // Half the quads go to the named graph, and the two halves are kept
9939        // lexically apart (`a…` / `b…`) so each lands in its own dictionary
9940        // chunks — the real layout on a quads file whose graphs use different
9941        // IRI namespaces, e.g. `cordis.rete`. Interleaving them instead would
9942        // put a term of both graphs in every chunk, and then no per-graph
9943        // access pattern could avoid faulting the whole dictionary.
9944        let quads: Vec<(String, String, String, bool)> = (0..2000u32)
9945            .map(|i| {
9946                let named = i % 2 == 0;
9947                let ns = if named { 'a' } else { 'b' };
9948                (
9949                    format!("<http://ex/{ns}/s/{i:04}>"),
9950                    "<http://ex/abstract>".to_string(),
9951                    format!("\"{ns}{} number {i}\"", payload(i)),
9952                    named,
9953                )
9954            })
9955            .collect();
9956        let mut db = DictionaryBuilder::new();
9957        for (s, p, o, _) in &quads {
9958            db.observe(s, p, o);
9959        }
9960        let dict = db.build();
9961        let mut def = GraphIndexBuilder::new().with_tile_budget(256);
9962        let mut g1 = GraphIndexBuilder::new().with_tile_budget(256);
9963        for (s, p, o, named) in &quads {
9964            let t = dict.encode(s, p, o).unwrap();
9965            if *named {
9966                g1.push(t);
9967            } else {
9968                def.push(t);
9969            }
9970        }
9971        let named = vec![("http://ex/g1".to_string(), g1.build())];
9972        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
9973
9974        let eager = Rete::open(&bytes).unwrap();
9975        let dict_len = eager.header().dictionary_len;
9976
9977        // `probe` runs one closure against a COLD lazy open and reports the
9978        // bytes it faulted beyond the open itself.
9979        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
9980        let probe = |f: &dyn Fn(&Rete) -> Vec<TermTriple>| -> (Vec<TermTriple>, u64) {
9981            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
9982            let lazy = Rete::open_ranged_lazy_with(reader.clone(), HEADER_LEN as u64).unwrap();
9983            let before = reader.bytes_read();
9984            let out = f(&lazy);
9985            assert!(!lazy.index_incomplete(), "a chunk fetch failed");
9986            (out, reader.bytes_read() - before)
9987        };
9988
9989        // 1. One bound subject in the named graph.
9990        let want = eager.query_in_graph(
9991            Some("http://ex/g1"),
9992            Some("<http://ex/a/s/0100>"),
9993            None,
9994            None,
9995        );
9996        assert_eq!(want.len(), 1, "fixture: expected exactly one match");
9997        let (got, pulled) = probe(&|r| {
9998            r.query_in_graph(
9999                Some("http://ex/g1"),
10000                Some("<http://ex/a/s/0100>"),
10001                None,
10002                None,
10003            )
10004        });
10005        assert_eq!(got, want, "lazy query_in_graph disagrees with eager");
10006        assert!(
10007            pulled * 4 < dict_len,
10008            "a 1-row query_in_graph faulted {pulled} B of a {dict_len} B dictionary — \
10009             it is prefetching the whole thing again"
10010        );
10011
10012        // 2. A graph with NOTHING to resolve must not fault the dictionary.
10013        //    Both shapes below used to pull every chunk before returning an
10014        //    empty answer, and `rete export` hits the second one first on a
10015        //    quads file whose default graph is empty — on `cordis.rete` that
10016        //    was 415 MB read and 2.5 GB of peak RSS to emit zero triples. What
10017        //    is left is the named-graph directory walk it takes to learn the
10018        //    graph is absent at all; that is index, not dictionary.
10019        let (empty, q_pulled) =
10020            probe(&|r| r.query_in_graph(Some("http://ex/nope"), None, None, None));
10021        assert!(empty.is_empty());
10022        let (_, d_pulled) = probe(&|r| {
10023            let mut n = 0usize;
10024            r.dump_each(Some("http://ex/nope"), |_, _, _| n += 1);
10025            assert_eq!(n, 0, "an absent graph yielded triples");
10026            Vec::new()
10027        });
10028        assert_eq!(
10029            q_pulled, d_pulled,
10030            "query_in_graph and dump_each disagree on what an absent graph costs"
10031        );
10032        assert!(
10033            q_pulled * 4 < dict_len,
10034            "an absent graph faulted {q_pulled} B of a {dict_len} B dictionary"
10035        );
10036
10037        // 3. Dumping ONE named graph costs that graph, not the whole file: the
10038        //    default graph's half of the dictionary must stay unfaulted.
10039        let want: Vec<TermTriple> = eager.dump(Some("http://ex/g1"));
10040        assert_eq!(want.len(), 1000);
10041        let (got, pulled) = probe(&|r| {
10042            let mut out = Vec::new();
10043            r.dump_each(Some("http://ex/g1"), |s, p, o| {
10044                out.push((s.to_string(), p.to_string(), o.to_string()))
10045            });
10046            out
10047        });
10048        assert_eq!(got, want, "lazy dump_each disagrees with eager dump");
10049        assert!(
10050            pulled * 3 < dict_len * 2,
10051            "dumping half the graph faulted {pulled} B of a {dict_len} B dictionary — \
10052             the other graph's chunks are being pulled in too"
10053        );
10054    }
10055
10056    /// Build a multi-chunk single-graph ranged file image for the eviction
10057    /// tests: 4000 distinct ~70-byte object literals overflow the 64 KiB
10058    /// dictionary chunk budget into several chunks, and one multi-KiB literal
10059    /// forces a large run/chunk boundary — the shape a tiny budget must survive.
10060    #[cfg(test)]
10061    fn build_multichunk_image() -> Vec<u8> {
10062        use crate::index::PermSet;
10063        let mut db = DictionaryBuilder::new();
10064        let mut triples: Vec<(String, String, String)> = (0..4000u32)
10065            .map(|i| {
10066                (
10067                    format!("<http://ex/s/{i:07}>"),
10068                    format!("<http://ex/p/{}>", i % 8),
10069                    format!("\"object literal number {i:07} with padding text to widen the term\""),
10070                )
10071            })
10072            .collect();
10073        triples.push((
10074            "<http://ex/s/big>".to_string(),
10075            "<http://ex/p/big>".to_string(),
10076            format!("\"{}\"", "Z".repeat(8000)),
10077        ));
10078        for (s, p, o) in &triples {
10079            db.observe(s, p, o);
10080        }
10081        let dict = db.build();
10082        let mut idx = GraphIndexBuilder::new()
10083            .with_tile_budget(64)
10084            .with_perms(PermSet::ALL);
10085        for (s, p, o) in &triples {
10086            idx.push(dict.encode(s, p, o).unwrap());
10087        }
10088        write_dataset(&dict, &idx.build(), &[], true, &[], 0)
10089    }
10090
10091    /// **Bounded-export phase 2: eviction is byte-transparent, and progress is
10092    /// guaranteed at any cap.** A cap far below the decompressed dictionary
10093    /// forces the chunk cache to evict during a full dump, yet the dump is
10094    /// byte-for-byte the unlimited-cap dump — the `Arc` handout keeps every
10095    /// chunk alive across its one decode, so eviction never drops bytes in use.
10096    /// A 1-byte cap (smaller than any single chunk, so every insert evicts
10097    /// itself immediately) still completes with identical output: it is the
10098    /// handout invariant, not residency, that makes a window make progress.
10099    #[test]
10100    fn tiny_dict_cache_evicts_yet_dump_is_byte_identical() {
10101        use crate::reader::SliceReader;
10102        let leaked: &'static [u8] = Box::leak(build_multichunk_image().into_boxed_slice());
10103        let dump = |cap: u64| -> (
10104            Vec<(String, String, String)>,
10105            crate::chunk_cache::CacheStats,
10106        ) {
10107            let rete =
10108                Rete::open_ranged_lazy_with(SliceReader::new(leaked), HEADER_LEN as u64).unwrap();
10109            rete.dict.set_cache_cap(cap);
10110            let mut out = Vec::new();
10111            rete.dump_filtered_each(None, None, None, None, |s, p, o| {
10112                out.push((s.to_string(), p.to_string(), o.to_string()))
10113            });
10114            assert!(
10115                !rete.index_incomplete(),
10116                "no lazy fault should fail at cap {cap}"
10117            );
10118            let stats = rete.dict_cache_stats();
10119            (out, stats)
10120        };
10121
10122        let (unlimited, us) = dump(u64::MAX);
10123        assert_eq!(unlimited.len(), 4001, "every triple dumped");
10124        assert_eq!(us.evictions, 0, "unlimited cap evicts nothing");
10125
10126        // A cap far below the decompressed dictionary: eviction happens, output
10127        // unchanged.
10128        let (tiny, ts) = dump(128 * 1024);
10129        assert_eq!(tiny, unlimited, "128 KiB-cap dump differs from unlimited");
10130        assert!(ts.evictions > 0, "a tiny cap must evict (stats {ts:?})");
10131
10132        // Progress guarantee: a 1-byte cap still completes, byte-identical.
10133        let (starved, ss) = dump(1);
10134        assert_eq!(starved, unlimited, "1-byte-cap dump differs from unlimited");
10135        assert!(
10136            ss.evictions > 0,
10137            "1-byte cap evicts every insert (stats {ss:?})"
10138        );
10139    }
10140
10141    /// **The budget pays for the open's fixed directories.** A ranged open holds
10142    /// one descriptor per dictionary chunk and per index tile for its whole
10143    /// life, outside every cache — 88 MB on the 14.75 GiB openaire-relation,
10144    /// 224 MB on epfl-infoscience. `set_memory_budget` used to give the caches
10145    /// the whole budget on top of that; it now measures the directories
10146    /// ([`Rete::fixed_overhead`]) and takes them out of the dictionary share, so
10147    /// caps + directories stay within the total. This checks the arithmetic on
10148    /// a many-chunk file, the 64 MiB floor, the unlimited pass-through, and that
10149    /// a dump under the capped budget keeps the dictionary cache within its
10150    /// (net) share.
10151    #[test]
10152    fn memory_budget_charges_the_fixed_directories() {
10153        use crate::reader::SliceReader;
10154        let leaked: &'static [u8] = Box::leak(build_multichunk_image().into_boxed_slice());
10155        let rete =
10156            Rete::open_ranged_lazy_with(SliceReader::new(leaked), HEADER_LEN as u64).unwrap();
10157
10158        let fixed = rete.fixed_overhead();
10159        // Every chunk and every tile has a descriptor; a multi-chunk dictionary
10160        // and a 64-triple tile budget make both directories non-trivial.
10161        assert!(
10162            fixed.dict_directory > 0 && fixed.index_directory > 0,
10163            "{fixed:?}"
10164        );
10165        assert_eq!(fixed.named_graphs, 0, "no named graphs in this file");
10166        assert_eq!(
10167            fixed.total(),
10168            fixed.dict_directory + fixed.index_directory + fixed.named_graphs
10169        );
10170
10171        // A budget well above the floor: the dictionary share is the plain
10172        // split less the directories, exactly.
10173        let total: u64 = 512 << 20;
10174        let applied = rete.set_memory_budget(Some(total));
10175        let plain = split_memory_budget(total);
10176        assert_eq!(applied.dict_cache, plain.dict_cache - fixed.total());
10177        assert_eq!(rete.dict_cache_cap(), applied.dict_cache);
10178        assert_eq!(applied.tile_cache, plain.tile_cache);
10179        assert_eq!(applied.block_cache, plain.block_cache);
10180        assert!(
10181            applied.dict_cache + applied.tile_cache + applied.block_cache + fixed.total() <= total,
10182            "caps + fixed directories exceed the budget: {applied:?} + {fixed:?} > {total}"
10183        );
10184
10185        // A budget whose share would fall under the floor keeps the floor.
10186        let applied = rete.set_memory_budget(Some(1 << 20));
10187        assert_eq!(applied.dict_cache, MIN_DICT_CACHE_CAP);
10188
10189        // Unlimited stays unlimited: nothing is subtracted from u64::MAX.
10190        let applied = rete.set_memory_budget(None);
10191        assert_eq!(applied.dict_cache, u64::MAX);
10192        assert_eq!(rete.dict_cache_cap(), u64::MAX);
10193
10194        // Under a cap smaller than the decompressed dictionary, a full dump
10195        // keeps the cache within it.
10196        let rete =
10197            Rete::open_ranged_lazy_with(SliceReader::new(leaked), HEADER_LEN as u64).unwrap();
10198        rete.dict.set_cache_cap(128 * 1024);
10199        let mut rows = 0usize;
10200        rete.dump_filtered_each(None, None, None, None, |_, _, _| rows += 1);
10201        assert_eq!(rows, 4001);
10202        let s = rete.dict_cache_stats();
10203        assert!(s.evictions > 0, "the cap must bind: {s:?}");
10204        // One oversized chunk may sit alone above the cap (the handout
10205        // invariant); otherwise residency is within it.
10206        assert!(
10207            s.entries <= 1 || s.resident_bytes <= 128 * 1024,
10208            "dictionary cache over its cap after the dump: {s:?}"
10209        );
10210    }
10211
10212    /// **Bounded-export phase 2: per-graph index release.** After a named
10213    /// graph's slot is exported, [`Rete::release_named_graph`] drops its decoded
10214    /// index (and its tiles) so a many-graph dump holds one graph's index at a
10215    /// time, not all of them. The graph re-opens lazily if touched again.
10216    #[test]
10217    fn releasing_a_named_graph_drops_and_reopens_its_index() {
10218        use crate::index::PermSet;
10219        use crate::reader::SliceReader;
10220        let mut db = DictionaryBuilder::new();
10221        let all: Vec<(String, String, String)> = (0..40u32)
10222            .map(|i| {
10223                (
10224                    format!("<http://ex/s{}>", i % 7),
10225                    format!("<http://ex/p{}>", i % 3),
10226                    format!("<http://ex/o{}>", i % 5),
10227                )
10228            })
10229            .collect();
10230        for (s, p, o) in &all {
10231            db.observe(s, p, o);
10232        }
10233        let dict = db.build();
10234        let mk = |take: fn(u32) -> bool| {
10235            let mut g = GraphIndexBuilder::new()
10236                .with_tile_budget(16)
10237                .with_perms(PermSet::ALL);
10238            for (i, (s, p, o)) in all.iter().enumerate() {
10239                if take(i as u32) {
10240                    g.push(dict.encode(s, p, o).unwrap());
10241                }
10242            }
10243            g.build()
10244        };
10245        let def = mk(|_| true);
10246        let g1 = mk(|i| i % 2 == 0);
10247        let g2 = mk(|i| i % 3 == 0);
10248        let image = write_dataset(
10249            &dict,
10250            &def,
10251            &[
10252                ("<http://ex/g1>".to_string(), g1),
10253                ("<http://ex/g2>".to_string(), g2),
10254            ],
10255            true,
10256            &[],
10257            0,
10258        );
10259        let leaked: &'static [u8] = Box::leak(image.into_boxed_slice());
10260        let mut rete =
10261            Rete::open_ranged_lazy_with(SliceReader::new(leaked), HEADER_LEN as u64).unwrap();
10262
10263        let opened = |r: &Rete| -> usize {
10264            match &r.named_graphs {
10265                NamedGraphsSlot::Lazy(l) => {
10266                    let mut n = 0;
10267                    l.for_each_opened(|_| n += 1);
10268                    n
10269                }
10270                _ => 0,
10271            }
10272        };
10273
10274        assert_eq!(opened(&rete), 0, "nothing opened before first touch");
10275        // Touching g1 opens its index.
10276        assert!(rete.graph_index("<http://ex/g1>").is_some());
10277        assert_eq!(opened(&rete), 1, "g1 opened");
10278        // Release it: the index is dropped.
10279        rete.release_named_graph("<http://ex/g1>");
10280        assert_eq!(opened(&rete), 0, "g1 released");
10281        // Releasing an unknown / already-released graph is a no-op.
10282        rete.release_named_graph("<http://ex/absent>");
10283        rete.release_named_graph("<http://ex/g1>");
10284        assert_eq!(opened(&rete), 0);
10285        // The graph re-opens lazily on the next access.
10286        assert!(rete.graph_index("<http://ex/g1>").is_some());
10287        assert_eq!(opened(&rete), 1, "g1 re-opened after release");
10288    }
10289
10290    /// **The filtered-dump correctness invariant** (#117 point 1): for every
10291    /// graph and every one of the eight bound/unbound shapes,
10292    /// `dump_filtered_each` must return *exactly* the quads `dump_each` returns,
10293    /// filtered — no more, and above all no fewer.
10294    ///
10295    /// "No fewer" is the whole risk. The speedup comes from *not fetching* tiles
10296    /// a synopsis says cannot match, and a pruning bug does not crash, it
10297    /// silently returns a shorter answer. So the oracle here is the unfiltered
10298    /// dump with a plain Rust `retain` over it — the thing the filter is meant
10299    /// to be a cheaper spelling of — and it is checked on every open path, since
10300    /// the eager path prunes with a different code path than the lazy one.
10301    ///
10302    /// It runs on a `PermSet::CORE` (`--permutations 3`) file as well as a
10303    /// six-permutation one. Every shape routes inside CORE — `perm_routing_never_leaves_core`
10304    /// proves the routing table does — so the two must agree row for row, and if
10305    /// a future shape ever routed outside CORE this test fails on the three-perm
10306    /// build rather than silently returning nothing from an absent section.
10307    #[test]
10308    fn filtered_dump_is_exactly_the_unfiltered_dump_filtered() {
10309        use crate::index::PermSet;
10310        use crate::reader::SliceReader;
10311
10312        let terms = |i: u32| {
10313            (
10314                format!("<http://ex/s{}>", i % 11),
10315                format!("<http://ex/p{}>", i % 3),
10316                // Mix IRIs and literals in the object column: a literal lives in
10317                // a different dictionary section than a node, and the object
10318                // filter has to resolve through the right one.
10319                if i.is_multiple_of(4) {
10320                    format!("\"lit {}\"@en", i % 5)
10321                } else {
10322                    format!("<http://ex/o{}>", i % 7)
10323                },
10324            )
10325        };
10326        let build = |perms: PermSet| {
10327            let mut db = DictionaryBuilder::new();
10328            let all: Vec<_> = (0..90u32).map(terms).collect();
10329            for (s, p, o) in &all {
10330                db.observe(s, p, o);
10331            }
10332            let dict = db.build();
10333            let mut def = GraphIndexBuilder::new()
10334                .with_tile_budget(16)
10335                .with_perms(perms);
10336            let mut g1 = GraphIndexBuilder::new()
10337                .with_tile_budget(16)
10338                .with_perms(perms);
10339            for (i, (s, p, o)) in all.iter().enumerate() {
10340                let t = dict.encode(s, p, o).unwrap();
10341                // Deliberately overlapping, not disjoint: every second triple is
10342                // in both graphs, so a filter that leaked across graphs would
10343                // still look plausible on counts alone.
10344                if i % 2 == 0 {
10345                    g1.push(t);
10346                }
10347                def.push(t);
10348            }
10349            write_dataset(
10350                &dict,
10351                &def.build(),
10352                &[("<http://ex/g1>".to_string(), g1.build())],
10353                true,
10354                &[],
10355                0,
10356            )
10357        };
10358
10359        let shapes: Vec<(Option<&str>, Option<&str>, Option<&str>)> = vec![
10360            (None, None, None),
10361            (Some("<http://ex/s3>"), None, None),
10362            (None, Some("<http://ex/p1>"), None),
10363            (None, None, Some("<http://ex/o5>")),
10364            (None, None, Some("\"lit 0\"@en")),
10365            (Some("<http://ex/s3>"), Some("<http://ex/p0>"), None),
10366            (Some("<http://ex/s4>"), None, Some("<http://ex/o4>")),
10367            (None, Some("<http://ex/p1>"), Some("<http://ex/o5>")),
10368            (
10369                Some("<http://ex/s3>"),
10370                Some("<http://ex/p0>"),
10371                Some("<http://ex/o3>"),
10372            ),
10373            // Terms the dictionary has never seen, in each position: these must
10374            // return nothing, not everything (an unresolvable bound term must
10375            // not silently degrade to "unbound").
10376            (Some("<http://ex/nope>"), None, None),
10377            (None, Some("<http://ex/nope>"), None),
10378            (None, None, Some("<http://ex/nope>")),
10379        ];
10380
10381        let mut any_pruned = false;
10382        for perms in [PermSet::ALL, PermSet::CORE] {
10383            let image = build(perms);
10384            let leaked: &'static [u8] = Box::leak(image.clone().into_boxed_slice());
10385            let opens: Vec<(&str, Rete)> = vec![
10386                ("resident", Rete::open(&image).unwrap()),
10387                (
10388                    "ranged",
10389                    Rete::open_ranged(&SliceReader::new(leaked)).unwrap(),
10390                ),
10391                (
10392                    "lazy",
10393                    Rete::open_ranged_lazy_with(SliceReader::new(leaked), HEADER_LEN as u64)
10394                        .unwrap(),
10395                ),
10396            ];
10397            for graph in [None, Some("<http://ex/g1>"), Some("<http://ex/absent>")] {
10398                // The oracle: the unfiltered dump, filtered in Rust.
10399                let full: Vec<TermTriple> = {
10400                    let mut v = Vec::new();
10401                    Rete::open(&image).unwrap().dump_each(graph, |s, p, o| {
10402                        v.push((s.to_string(), p.to_string(), o.to_string()))
10403                    });
10404                    v.sort();
10405                    v
10406                };
10407                for &(s, p, o) in &shapes {
10408                    let mut want = full.clone();
10409                    want.retain(|(ts, tp, to)| {
10410                        s.is_none_or(|x| x == ts)
10411                            && p.is_none_or(|x| x == tp)
10412                            && o.is_none_or(|x| x == to)
10413                    });
10414                    for (path, rete) in &opens {
10415                        let mut got = Vec::new();
10416                        rete.dump_filtered_each(graph, s, p, o, |s, p, o| {
10417                            got.push((s.to_string(), p.to_string(), o.to_string()))
10418                        });
10419                        got.sort();
10420                        assert_eq!(
10421                            got,
10422                            want,
10423                            "{path}/{perms:?} graph={graph:?} pattern={:?} — filtered dump \
10424                             is not the unfiltered dump filtered",
10425                            (s, p, o)
10426                        );
10427                        assert!(!rete.index_incomplete(), "{path}: a fetch failed");
10428                    }
10429                    // …and the plan must agree with what the dump actually did:
10430                    // an empty answer means either no scan at all, or a scan
10431                    // whose admitted tiles simply held nothing.
10432                    let plan = opens[2].1.dump_plan(graph, s, p, o);
10433                    if graph == Some("<http://ex/absent>")
10434                        || [s, p, o].contains(&Some("<http://ex/nope>"))
10435                    {
10436                        assert!(
10437                            plan.scan.is_none(),
10438                            "an unmatchable dump still planned a scan: {:?}",
10439                            (graph, s, p, o)
10440                        );
10441                    } else {
10442                        let scan = plan.scan.expect("a matchable dump plans a scan");
10443                        assert!(
10444                            PermSet::CORE.contains(scan.permutation),
10445                            "a dump routed to {:?}, outside PermSet::CORE — a \
10446                             --permutations 3 file has no such section",
10447                            scan.permutation
10448                        );
10449                        assert!(scan.tiles_admitted <= scan.tiles_routed);
10450                        assert!(scan.tiles_routed <= scan.tiles_total);
10451                        assert!(scan.tile_bytes <= scan.section_bytes);
10452                        if scan.tiles_admitted < scan.tiles_total {
10453                            any_pruned = true;
10454                        }
10455                    }
10456                }
10457            }
10458        }
10459        assert!(
10460            any_pruned,
10461            "no shape pruned a single tile — the fixture is too small to be \
10462             measuring anything"
10463        );
10464    }
10465
10466    /// The point of the filter: a predicate-scoped dump of a lazily range-read
10467    /// file must **fetch less** than the same graph's full dump, not merely
10468    /// return less.
10469    ///
10470    /// The fixture is the shape the win shows on — many tiles, and a predicate
10471    /// whose triples live in a minority of them — so the assertion can be about
10472    /// bytes rather than about the code path being taken on faith. Measured on
10473    /// the real thing (`cordis.rete`, 801 MB): one predicate of one named graph
10474    /// read 16.0 MB where the graph's full dump read 375.8 MB.
10475    #[test]
10476    fn a_predicate_scoped_dump_fetches_less_than_the_graph() {
10477        use crate::reader::{CountingReader, SliceReader};
10478
10479        // 200 subjects × 20 predicates, tiled small so there are many tiles, and
10480        // one distinct object per cell so that a predicate slice needs a
10481        // twentieth of the object dictionary rather than all of it — otherwise
10482        // the dictionary, which this change does not prune, swamps the index
10483        // saving the test is about.
10484        let mut db = DictionaryBuilder::new();
10485        let mut all = Vec::new();
10486        for s in 0..200u32 {
10487            for p in 0..20u32 {
10488                let t = (
10489                    format!("<http://ex/s/{s:04}>"),
10490                    format!("<http://ex/p/{p:02}>"),
10491                    format!("\"object {s:04}/{p:02}\""),
10492                );
10493                db.observe(&t.0, &t.1, &t.2);
10494                all.push(t);
10495            }
10496        }
10497        let dict = db.build();
10498        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
10499        for (s, p, o) in &all {
10500            ib.push(dict.encode(s, p, o).unwrap());
10501        }
10502        let image = write_dataset(&dict, &ib.build(), &[], false, &[], 0);
10503        let leaked: &'static [u8] = Box::leak(image.clone().into_boxed_slice());
10504
10505        // Bytes faulted past the open, for one dump.
10506        let probe = |s: Option<&str>, p: Option<&str>, o: Option<&str>| -> (usize, u64) {
10507            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
10508            let lazy = Rete::open_ranged_lazy_with(reader.clone(), HEADER_LEN as u64).unwrap();
10509            let before = reader.bytes_read();
10510            let mut n = 0usize;
10511            lazy.dump_filtered_each(None, s, p, o, |_, _, _| n += 1);
10512            assert!(!lazy.index_incomplete());
10513            (n, reader.bytes_read() - before)
10514        };
10515
10516        let (full_rows, full_bytes) = probe(None, None, None);
10517        let (pred_rows, pred_bytes) = probe(None, Some("<http://ex/p/07>"), None);
10518        let (subj_rows, subj_bytes) = probe(Some("<http://ex/s/0021>"), None, None);
10519        assert_eq!(full_rows, 4000);
10520        assert_eq!(pred_rows, 200, "one predicate covers every subject once");
10521        assert_eq!(subj_rows, 20);
10522        // True in any build: a slice fetches strictly less than the graph.
10523        assert!(
10524            pred_bytes < full_bytes && subj_bytes < full_bytes,
10525            "a slice fetched {pred_bytes} / {subj_bytes} B where the whole graph fetched \
10526             {full_bytes} B — the scan is not being pruned at all"
10527        );
10528        // The SIZE of the win is a property of the FILE, not of this change: it is
10529        // the index share of what a dump reads, and the dictionary — which no
10530        // filter prunes — is the rest. With the default `compression` feature the
10531        // dictionary chunk bodies are zstd-compressed and the slice costs about a
10532        // tenth of the graph (measured here: 1,565 B for the predicate and 864 B
10533        // for the subject, against 15,714 B). Built `--no-default-features` those
10534        // same chunks are stored RAW, so resolving 200 rows' terms dominates and
10535        // the identical, identically-pruned scan costs 22,225 B of 40,165 — 55%.
10536        // Asserting the strong ratio unconditionally would be asserting that zstd
10537        // is enabled; the index-side prune is checked below and holds either way.
10538        #[cfg(feature = "compression")]
10539        assert!(
10540            pred_bytes * 4 < full_bytes && subj_bytes * 4 < full_bytes,
10541            "a 1-in-20 predicate slice fetched {pred_bytes} B and one subject \
10542             {subj_bytes} B where the whole graph fetched {full_bytes} B"
10543        );
10544
10545        // And the plan predicted the index side of it without fetching a tile.
10546        let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
10547        let lazy = Rete::open_ranged_lazy_with(reader.clone(), HEADER_LEN as u64).unwrap();
10548        let before = reader.bytes_read();
10549        let plan = lazy
10550            .dump_plan(None, None, Some("<http://ex/p/07>"), None)
10551            .scan
10552            .expect("a known predicate plans a scan");
10553        assert!(
10554            reader.bytes_read() - before < 64 * 1024,
10555            "planning fetched {} B — it is supposed to read directories, not tiles",
10556            reader.bytes_read() - before
10557        );
10558        assert!(plan.tiles_admitted < plan.tiles_total);
10559        // The index side on its own, with the dictionary's share taken out: one
10560        // predicate of twenty routes to a handful of POS tiles. (Measured here:
10561        // the whole dump faults 15,714 B, the predicate slice 1,565 B and one
10562        // subject 864 B — a 10x and an 18x, most of what is left being the
10563        // dictionary this change does not prune.)
10564        assert!(
10565            plan.tile_bytes * 5 < plan.section_bytes,
10566            "one predicate of twenty planned {} B of a {} B section",
10567            plan.tile_bytes,
10568            plan.section_bytes
10569        );
10570    }
10571
10572    #[test]
10573    fn query_quads_tags_every_graph() {
10574        let mut db = DictionaryBuilder::new();
10575        for (s, p, o) in [("Alice", "knows", "Bob"), ("Alice", "knows", "Dave")] {
10576            db.observe(s, p, o);
10577        }
10578        let dict = db.build();
10579        let mut def = GraphIndexBuilder::new();
10580        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
10581        let mut g1 = GraphIndexBuilder::new();
10582        g1.push(dict.encode("Alice", "knows", "Dave").unwrap());
10583        let named = vec![("http://ex/g1".to_string(), g1.build())];
10584        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
10585        let rete = Rete::open(&bytes).unwrap();
10586
10587        // `Alice knows ?` spans both graphs; each match carries its graph tag.
10588        let quads = rete.query_quads(Some("Alice"), Some("knows"), None);
10589        assert_eq!(quads.len(), 2);
10590        assert_eq!(
10591            quads[0],
10592            (("Alice".into(), "knows".into(), "Bob".into()), None)
10593        );
10594        assert_eq!(
10595            quads[1],
10596            (
10597                ("Alice".into(), "knows".into(), "Dave".into()),
10598                Some("http://ex/g1".to_string())
10599            )
10600        );
10601
10602        // A bound term absent from the dictionary yields nothing, in any graph.
10603        assert!(rete.query_quads(Some("Nobody"), None, None).is_empty());
10604    }
10605
10606    #[test]
10607    fn pyramid_meta_round_trips_in_file() {
10608        let rete = Rete::open(&build_image()).unwrap();
10609        let pyr = rete.pyramid().expect("file has a pyramid");
10610        // Summary covers all 3 triples by count; tiles are not stored in v0.
10611        let total: u32 = pyr.summary.iter().map(|e| e.count).sum();
10612        assert_eq!(total, 3);
10613        assert!(!pyr.summary.is_empty());
10614        assert!(pyr.tiles.is_empty());
10615    }
10616
10617    #[test]
10618    fn schema_pyramid_round_trips_through_file_index_free() {
10619        use crate::reader::{CountingReader, SliceReader};
10620        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
10621        let q = |s: &str, p: &str, o: &str| {
10622            (s.to_string(), p.to_string(), o.to_string(), None::<String>)
10623        };
10624        // Astronomer ⊑ Scientist ⊑ Person ⊑ Agent, instances at the leaves.
10625        let quads = vec![
10626            q("<a>", RDF_TYPE, "<Astronomer>"),
10627            q("<b>", RDF_TYPE, "<Astronomer>"),
10628            q("<c>", RDF_TYPE, "<Person>"),
10629            q("<Astronomer>", sub, "<Scientist>"),
10630            q("<Scientist>", sub, "<Person>"),
10631            q("<Person>", sub, "<Agent>"),
10632            q("<a>", "<knows>", "<b>"),
10633            q("<b>", "<knows>", "<c>"),
10634        ];
10635        let (bytes, _) =
10636            crate::ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| Vec::new());
10637
10638        // The v2 schema pyramid round-trips through the built file.
10639        let rete = Rete::open(&bytes).unwrap();
10640        let pyr = rete.pyramid().expect("pyramid present");
10641        assert!(!pyr.level_rollups.is_empty(), "schema pyramid shipped");
10642        assert!(pyr
10643            .class_hierarchy
10644            .iter()
10645            .any(|n| n.class == "<Agent>" && n.depth == 0));
10646
10647        // It reads index-free: a SummaryView open never touches the index section.
10648        let r = CountingReader::new(SliceReader::new(&bytes));
10649        let view = SummaryView::open_ranged(&r).unwrap().unwrap();
10650        assert!(view.level_count() >= 2, "multi-level pyramid");
10651        let coarse = view.level_rollup(0).unwrap();
10652        assert!(
10653            coarse.classes.iter().any(|(c, _)| c == "<Agent>"),
10654            "coarsest level rolls up to the root Agent"
10655        );
10656        let h = Header::from_bytes(&bytes).unwrap();
10657        assert!(
10658            r.bytes_read() <= bytes.len() as u64 - h.root_dir_len,
10659            "summary read {} bytes; the {}-byte index section must be skipped",
10660            r.bytes_read(),
10661            h.root_dir_len
10662        );
10663    }
10664
10665    #[test]
10666    fn predicate_totals_from_summary_only() {
10667        use crate::reader::SliceReader;
10668        // build_image: 2 `knows` triples + 1 `age` triple.
10669        let bytes = build_image();
10670        let reader = SliceReader::new(&bytes);
10671        let view = SummaryView::open_ranged(&reader).unwrap().unwrap();
10672        assert_eq!(view.predicate_total("knows"), 2);
10673        assert_eq!(view.predicate_total("age"), 1);
10674        assert_eq!(view.predicate_total("missing"), 0);
10675        let totals = view.predicate_totals();
10676        assert_eq!(totals[0], ("knows".to_string(), 2)); // sorted by count desc
10677    }
10678
10679    #[test]
10680    fn query_patterns_resolve_to_terms() {
10681        let rete = Rete::open(&build_image()).unwrap();
10682
10683        // All triples.
10684        assert_eq!(rete.query(None, None, None).len(), 3);
10685
10686        // Subject bound.
10687        let mut alice = rete.query(Some("Alice"), None, None);
10688        alice.sort();
10689        assert_eq!(
10690            alice,
10691            vec![
10692                ("Alice".into(), "age".into(), "30".into()),
10693                ("Alice".into(), "knows".into(), "Bob".into()),
10694            ]
10695        );
10696
10697        // Predicate bound.
10698        assert_eq!(rete.query(None, Some("knows"), None).len(), 2);
10699
10700        // Full triple, present and absent.
10701        assert_eq!(
10702            rete.query(Some("Bob"), Some("knows"), Some("Carol")),
10703            vec![("Bob".into(), "knows".into(), "Carol".into())]
10704        );
10705        assert!(rete.query(Some("Nobody"), None, None).is_empty());
10706        assert!(rete.query(None, Some("likes"), None).is_empty());
10707    }
10708
10709    #[test]
10710    fn query_provenance_reports_terms_ids_sections_and_index_choice() {
10711        let bytes = build_image();
10712        let rete = Rete::open(&bytes).unwrap();
10713
10714        let mut matches = rete.query_with_provenance(None, Some("knows"), None);
10715        matches.sort_by(|a, b| a.terms.cmp(&b.terms));
10716
10717        assert_eq!(matches.len(), 2);
10718        assert_eq!(
10719            matches[0].terms,
10720            ("Alice".into(), "knows".into(), "Bob".into())
10721        );
10722        assert_eq!(
10723            matches[0].ids,
10724            rete.dictionary().encode("Alice", "knows", "Bob").unwrap()
10725        );
10726        assert_eq!(matches[0].graph.as_deref(), None);
10727        assert_eq!(
10728            matches[0].matched_pattern,
10729            (None, Some(matches[0].ids.1), None)
10730        );
10731        assert_eq!(
10732            matches[0].index_permutation,
10733            crate::index::IndexPermutation::Pos
10734        );
10735
10736        let h = rete.header();
10737        assert_eq!(matches[0].dictionary_range.offset, h.dictionary_offset);
10738        assert_eq!(matches[0].dictionary_range.len, h.dictionary_len);
10739        assert_eq!(matches[0].index_range.offset, h.root_dir_offset);
10740        assert_eq!(matches[0].index_range.len, h.root_dir_len);
10741        assert!(
10742            matches[0].index_section_range.offset > h.root_dir_offset,
10743            "POS is section 1, so its payload starts after the container header and SPO payload"
10744        );
10745        assert!(matches[0].index_section_range.len > 0);
10746        assert!(matches[0].index_section_range.end() <= matches[0].index_range.end());
10747        assert!(matches[0].index_section_range.len < matches[0].index_range.len);
10748        assert_eq!(
10749            matches[0].pyramid_range.as_ref().map(|r| (r.offset, r.len)),
10750            Some((h.pyramid_meta_offset, h.pyramid_meta_len))
10751        );
10752        // Tiled (v0.2) files report the physical tile holding the match; its
10753        // compressed byte range nests inside the selected section payload.
10754        let tile_range = matches[0].tile_range.expect("tiled file reports a tile");
10755        assert!(matches[0]
10756            .tile
10757            .as_deref()
10758            .unwrap()
10759            .starts_with(matches[0].index_permutation.name()));
10760        assert!(matches[0].index_section_range.offset <= tile_range.offset);
10761        assert!(tile_range.end() <= matches[0].index_section_range.end());
10762    }
10763
10764    /// Build an in-memory `.rete` with `n` labeled subjects: each carries an
10765    /// `rdfs:label` literal drawn from `WORDS` (a word prefix selects ~1/|WORDS|
10766    /// of them) plus one extra edge so the subject has a degree to rank by.
10767    fn build_labeled(n: usize) -> Vec<u8> {
10768        const LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
10769        const WORDS: &[&str] = &[
10770            "alanine",
10771            "benzene",
10772            "glucose",
10773            "dextrose",
10774            "ethanol",
10775            "formate",
10776            "heptane",
10777            "isoleucine",
10778        ];
10779        let triples: Vec<(String, String, String)> = (0..n)
10780            .flat_map(|i| {
10781                let s = format!("<http://ex/e{i}>");
10782                let w = WORDS[i % WORDS.len()];
10783                [
10784                    (s.clone(), LABEL.to_string(), format!("\"{w}-{i:06}\"")),
10785                    (
10786                        s,
10787                        "<http://ex/p>".to_string(),
10788                        format!("<http://ex/c{}>", i % 64),
10789                    ),
10790                ]
10791            })
10792            .collect();
10793        let mut db = DictionaryBuilder::new();
10794        for (s, p, o) in &triples {
10795            db.observe(s, p, o);
10796        }
10797        let dict = db.build();
10798        let ids: Vec<(u32, u32, u32)> = triples
10799            .iter()
10800            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
10801            .collect();
10802        let mut ib = GraphIndexBuilder::new();
10803        for &t in &ids {
10804            ib.push(t);
10805        }
10806        let (meta, levels) = build_pyramid_meta(&dict, &ids, DEFAULT_TILE_BUDGET);
10807        write_file(&dict, &ib.build(), false, &meta, levels)
10808    }
10809
10810    #[test]
10811    fn prefix_search_matches_a_filter_scan() {
10812        // 800 < the 8192 label-index cap, so the index is COMPLETE — every label
10813        // is present and the two paths must return the exact same subject set.
10814        let bytes = build_labeled(800);
10815        let rete = Rete::open(&bytes).unwrap();
10816        let idx_subjects: std::collections::BTreeSet<String> = rete
10817            .prefix_search("glucose", 10_000)
10818            .into_iter()
10819            .map(|(_label, subject)| subject)
10820            .collect();
10821        // The same selection via a SPARQL FILTER scan over every label literal.
10822        let q = "SELECT ?s WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?l \
10823                 FILTER(STRSTARTS(LCASE(?l), \"glucose\")) }";
10824        let crate::QueryOutput::Select(_, rows) = crate::eval_query(&rete, q).unwrap() else {
10825            panic!("expected SELECT");
10826        };
10827        let scan_subjects: std::collections::BTreeSet<String> =
10828            rows.iter().map(|r| r.get("s").cloned().unwrap()).collect();
10829        assert_eq!(idx_subjects, scan_subjects, "index agrees with the scan");
10830        assert_eq!(
10831            idx_subjects.len(),
10832            100,
10833            "800/8 words = 100 glucose-* labels"
10834        );
10835    }
10836
10837    /// A legacy (pyramid-block) label search resolves its hits' subjects in one
10838    /// batched dictionary read on a lazy open — the label-search twin of the
10839    /// full-text hits' batch — instead of one dependent read per chunk.
10840    #[test]
10841    fn legacy_label_hits_resolve_in_one_batched_read() {
10842        use std::sync::{Arc, Mutex};
10843        type CallLog = Arc<Mutex<Vec<Vec<(u64, u64)>>>>;
10844        #[derive(Clone)]
10845        struct Calls(Arc<Vec<u8>>, CallLog);
10846        impl crate::RangeReader for Calls {
10847            fn len(&self) -> u64 {
10848                self.0.len() as u64
10849            }
10850            fn read_at(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
10851                self.1.lock().unwrap().push(vec![(offset, len)]);
10852                crate::SliceReader::new(&self.0).read_at(offset, len)
10853            }
10854            fn read_many(&self, ranges: &[(u64, u64)]) -> std::io::Result<Vec<Vec<u8>>> {
10855                self.1.lock().unwrap().push(ranges.to_vec());
10856                let r = crate::SliceReader::new(&self.0);
10857                ranges.iter().map(|&(o, l)| r.read_at(o, l)).collect()
10858            }
10859        }
10860        let bytes = build_labeled(20_000);
10861        let h = Header::from_bytes(&bytes).unwrap();
10862        let (d0, dl) = (h.dictionary_offset, h.dictionary_len);
10863        let want = Rete::open(&bytes).unwrap().prefix_search("glucose", 1000);
10864        assert_eq!(want.len(), 1000);
10865        for search_view in [false, true] {
10866            let calls = Calls(Arc::new(bytes.clone()), Arc::new(Mutex::new(Vec::new())));
10867            let got = if search_view {
10868                let view = SearchView::open_ranged_with(calls.clone(), HEADER_LEN as u64).unwrap();
10869                calls.1.lock().unwrap().clear();
10870                view.prefix_search("glucose", 1000)
10871            } else {
10872                let rete = Rete::open_ranged_lazy_with(calls.clone(), HEADER_LEN as u64).unwrap();
10873                calls.1.lock().unwrap().clear();
10874                rete.prefix_search("glucose", 1000)
10875            };
10876            assert_eq!(got, want, "search view = {search_view}");
10877            let dict_reads = calls
10878                .1
10879                .lock()
10880                .unwrap()
10881                .iter()
10882                .filter(|c| c.iter().any(|&(o, l)| o < d0 + dl && d0 < o + l))
10883                .count();
10884            assert_eq!(dict_reads, 1, "search view = {search_view}");
10885        }
10886    }
10887
10888    /// Latency: the binary-search label index vs the FILTER scan it replaces.
10889    /// Ignored by default (timing-sensitive); run with
10890    /// `cargo test -p rete-core -- --ignored --nocapture bench_prefix_search`.
10891    #[test]
10892    #[ignore]
10893    fn bench_prefix_search_vs_filter_scan() {
10894        use std::time::Instant;
10895        let n = 6000; // < the 8192 cap, so both paths return identical sets
10896        let bytes = build_labeled(n);
10897        let rete = Rete::open(&bytes).unwrap();
10898        let q = "SELECT ?s WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?l \
10899                 FILTER(STRSTARTS(LCASE(?l), \"glucose\")) }";
10900        let reps = 200;
10901        let idx_n = rete.prefix_search("glucose", 100_000).len();
10902        let t = Instant::now();
10903        for _ in 0..reps {
10904            std::hint::black_box(rete.prefix_search("glucose", 100_000));
10905        }
10906        let idx_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
10907        let t = Instant::now();
10908        for _ in 0..reps {
10909            let _ = std::hint::black_box(crate::eval_query(&rete, q).unwrap());
10910        }
10911        let scan_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
10912        println!(
10913            "label prefix search over {n} labeled subjects ({idx_n} matches): \
10914             index {idx_ms:.4} ms vs FILTER scan {scan_ms:.3} ms ({:.0}× faster)",
10915            scan_ms / idx_ms
10916        );
10917    }
10918
10919    /// Latency: the TEXT_INDEX word search vs the `FILTER(CONTAINS(?l, …))` scan
10920    /// it replaces. Ignored by default (timing-sensitive); run with
10921    /// `cargo test -p rete-core -- --ignored --nocapture bench_text_search`.
10922    #[test]
10923    #[ignore]
10924    fn bench_text_search_vs_contains_scan() {
10925        use std::time::Instant;
10926        const LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
10927        const WORDS: &[&str] = &[
10928            "alanine",
10929            "benzene",
10930            "glucose",
10931            "dextrose",
10932            "ethanol",
10933            "formate",
10934            "heptane",
10935            "isoleucine",
10936        ];
10937        let n = 6000;
10938        let triples: Vec<(String, String, String)> = (0..n)
10939            .map(|i| {
10940                (
10941                    format!("<http://ex/e{i}>"),
10942                    LABEL.to_string(),
10943                    format!("\"{} sample number {i:06}\"", WORDS[i % WORDS.len()]),
10944                )
10945            })
10946            .collect();
10947        let mut db = DictionaryBuilder::new();
10948        for (s, p, o) in &triples {
10949            db.observe(s, p, o);
10950        }
10951        let dict = db.build();
10952        let ids: Vec<(u32, u32, u32)> = triples
10953            .iter()
10954            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
10955            .collect();
10956        let mut ib = GraphIndexBuilder::new();
10957        for &t in &ids {
10958            ib.push(t);
10959        }
10960        let ti = compute_text_index(&dict, &ids);
10961        let bytes = write_dataset_with_metadata(&dict, &ib.build(), &[], false, &[], 0, &[], &ti);
10962        let rete = Rete::open(&bytes).unwrap();
10963
10964        let q = "SELECT ?s WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?l \
10965                 FILTER(CONTAINS(LCASE(?l), \"glucose\")) }";
10966        let reps = 200;
10967        let idx_n = rete.text_search(&["glucose"], None, 100_000).len();
10968        let t = Instant::now();
10969        for _ in 0..reps {
10970            std::hint::black_box(rete.text_search(&["glucose"], None, 100_000));
10971        }
10972        let idx_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
10973        let t = Instant::now();
10974        for _ in 0..reps {
10975            let _ = std::hint::black_box(crate::eval_query(&rete, q).unwrap());
10976        }
10977        let scan_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
10978        println!(
10979            "text search over {n} literals ({idx_n} matches): \
10980             index {idx_ms:.4} ms vs FILTER(CONTAINS) scan {scan_ms:.3} ms ({:.0}× faster)",
10981            scan_ms / idx_ms
10982        );
10983    }
10984
10985    /// Operational debugging harness for a REAL on-disk file (ignored; driven
10986    /// by env vars): step-by-step dump of a bound (p, o) POS routing.
10987    ///   RETE_DEBUG_FILE=<path.rete> RETE_DEBUG_P=<iri> RETE_DEBUG_O=<iri>
10988    #[test]
10989    #[ignore = "operational tool, driven by RETE_DEBUG_* env vars"]
10990    fn debug_bound_po_routing() {
10991        struct FR(std::fs::File);
10992        impl crate::RangeReader for FR {
10993            fn len(&self) -> u64 {
10994                self.0.metadata().map(|m| m.len()).unwrap_or(0)
10995            }
10996            fn read_at(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
10997                use std::os::unix::fs::FileExt;
10998                let mut buf = vec![0u8; len as usize];
10999                self.0.read_exact_at(&mut buf, offset)?;
11000                Ok(buf)
11001            }
11002        }
11003        let path = std::env::var("RETE_DEBUG_FILE").expect("RETE_DEBUG_FILE");
11004        let p_iri = std::env::var("RETE_DEBUG_P").expect("RETE_DEBUG_P");
11005        let o_iri = std::env::var("RETE_DEBUG_O").expect("RETE_DEBUG_O");
11006        let rete = Rete::open_ranged_lazy_with(
11007            std::sync::Arc::new(FR(std::fs::File::open(&path).unwrap())),
11008            HEADER_LEN as u64,
11009        )
11010        .unwrap();
11011        let pid = rete.dict.predicate_id(&p_iri).expect("p resolves");
11012        let oid = rete.dict.object_id(&o_iri).expect("o resolves");
11013        eprintln!("pid={pid} oid={oid}");
11014        let pattern = (None, Some(pid), Some(oid));
11015        let perm = GraphIndex::best_permutation(pattern);
11016        eprintln!("best_permutation = {}", perm.name());
11017        let si = perm.section_index();
11018        let tiles = &rete.index.sections[si];
11019        eprintln!("section {} tiles = {}", perm.name(), tiles.len());
11020        let [pa, pb, pc] = perm.order_pattern(pattern);
11021        eprintln!("permuted pattern pa={pa:?} pb={pb:?} pc={pc:?}");
11022        let (start, end) = rete.index.tile_span(si, pa);
11023        eprintln!("tile_span = [{start}, {end}) -> {} tiles", end - start);
11024        let mut admitted = 0usize;
11025        for (ti, t) in tiles.iter().enumerate().take(end).skip(start) {
11026            if t.syn_admits(pb, pc) {
11027                admitted += 1;
11028                if admitted <= 10 {
11029                    let (lo, hi) = t.leading_range();
11030                    eprintln!("  admit tile {ti}: a=[{lo},{hi}] syn={:?}", t.syn);
11031                }
11032            }
11033        }
11034        eprintln!("admitted {admitted} tile(s) by synopsis");
11035        let n = rete.index.scan_iter(pattern).count();
11036        eprintln!("scan_iter matches = {n}");
11037        let hi_res = rete.query(None, Some(&p_iri), Some(&o_iri));
11038        eprintln!("high-level query matches = {}", hi_res.len());
11039    }
11040
11041    /// The header segment's LABEL and the span it describes must agree. They
11042    /// did not from v0.3 (which grew the header to 1 KiB) until this test: the
11043    /// label still said "fixed 128 bytes", the pre-v0.3 size, while the segment
11044    /// covered `HEADER_LEN` = 1024 — and that label is exactly what the file
11045    /// explorer shows someone inspecting a real file. Reading the size back out
11046    /// of the string is the point: it fails if anyone retypes the number
11047    /// instead of deriving it from `HEADER_LEN`.
11048    #[test]
11049    fn layout_header_label_matches_its_span() {
11050        let mut db = DictionaryBuilder::new();
11051        let mut triples = Vec::new();
11052        for i in 0..24u32 {
11053            let (s, p, o) = (
11054                format!("<http://ex/s{}>", i % 5),
11055                format!("<http://ex/p{}>", i % 3),
11056                format!("<http://ex/o{i}>"),
11057            );
11058            db.observe(&s, &p, &o);
11059            triples.push((s, p, o));
11060        }
11061        let dict = db.build();
11062        let ids: Vec<(u32, u32, u32)> = triples
11063            .iter()
11064            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
11065            .collect();
11066        let index = GraphIndexBuilder::from_triples(ids).build();
11067        let image = write_dataset_with_metadata(
11068            &dict,
11069            &index,
11070            &[],
11071            false,
11072            &[],
11073            0,
11074            br#"{"name":"layout-label-fixture"}"#,
11075            &[],
11076        );
11077        let rete = Rete::open(&image).unwrap();
11078
11079        let layout = rete.file_layout();
11080        let header = layout
11081            .iter()
11082            .find(|s| s.kind == "header")
11083            .expect("every layout starts with a header segment");
11084
11085        assert_eq!(
11086            header.offset, 0,
11087            "the header is the first thing in the file"
11088        );
11089        assert_eq!(
11090            header.len, HEADER_LEN as u64,
11091            "the header segment must span exactly HEADER_LEN"
11092        );
11093
11094        // Pull the number back out of the human-readable label and hold it to
11095        // the span. A hard-coded literal that drifts from HEADER_LEN dies here.
11096        let digits: String = header
11097            .label
11098            .chars()
11099            .skip_while(|c| !c.is_ascii_digit())
11100            .take_while(|c| c.is_ascii_digit())
11101            .collect();
11102        assert!(
11103            !digits.is_empty(),
11104            "header label {:?} states no size at all",
11105            header.label
11106        );
11107        assert_eq!(
11108            digits.parse::<u64>().unwrap(),
11109            header.len,
11110            "header label {:?} disagrees with the {}-byte span it describes",
11111            header.label,
11112            header.len
11113        );
11114
11115        // The layout must also stay sorted and start at byte zero, which is
11116        // what makes "the first segment is the header" meaningful.
11117        assert!(
11118            layout.windows(2).all(|w| w[0].offset <= w[1].offset),
11119            "file_layout must return segments sorted by offset"
11120        );
11121    }
11122}