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    /// [`name_at`](Self::name_at) without keeping the name: a quad-index walk
4014    /// over many graphs must not memoise one string per graph it passes.
4015    fn name_owned(&self, dict: &Dictionary, i: usize, exhaustive: bool) -> Option<String> {
4016        match self {
4017            NamedGraphsSlot::Quad(q) => q.name_owned(dict, i, exhaustive),
4018            _ => self.name_at(dict, i, exhaustive).map(str::to_owned),
4019        }
4020    }
4021
4022    fn graph_at<'a>(
4023        &'a self,
4024        dict: &Dictionary,
4025        i: usize,
4026        exhaustive: bool,
4027    ) -> Option<(&'a str, &'a GraphIndex)> {
4028        match self {
4029            NamedGraphsSlot::Resident(v) => v.get(i).map(|(iri, g)| (iri.as_str(), g)),
4030            NamedGraphsSlot::Lazy(l) => l.graph_at(i, exhaustive),
4031            NamedGraphsSlot::Quad(q) => q.graph_at(dict, i, exhaustive),
4032        }
4033    }
4034
4035    fn find<'a>(&'a self, dict: &Dictionary, iri: &str) -> Option<&'a GraphIndex> {
4036        match self {
4037            NamedGraphsSlot::Quad(q) => q.find(dict, iri),
4038            NamedGraphsSlot::Resident(v) => v.iter().find(|(name, _)| name == iri).map(|(_, g)| g),
4039            NamedGraphsSlot::Lazy(l) => {
4040                // Walk headers only until the IRI matches; decode just that
4041                // graph's index. An absent IRI costs a full header walk — the
4042                // layout has no by-name directory. A point lookup is targeted
4043                // demand: never hint the walk exhaustive.
4044                for i in 0..l.count() {
4045                    if l.name_at(i, false)? == iri {
4046                        return l.graph_at(i, false).map(|(_, g)| g);
4047                    }
4048                }
4049                None
4050            }
4051        }
4052    }
4053
4054    fn load_incomplete(&self) -> bool {
4055        match self {
4056            NamedGraphsSlot::Resident(v) => v.iter().any(|(_, g)| g.load_incomplete()),
4057            NamedGraphsSlot::Lazy(l) => {
4058                if l.failed.load(std::sync::atomic::Ordering::Relaxed) {
4059                    return true;
4060                }
4061                let mut bad = false;
4062                l.for_each_opened(|g| bad |= g.load_incomplete());
4063                bad
4064            }
4065            NamedGraphsSlot::Quad(q) => q.load_incomplete(),
4066        }
4067    }
4068
4069    fn reset_load_failures(&self) {
4070        match self {
4071            NamedGraphsSlot::Resident(v) => {
4072                for (_, g) in v {
4073                    g.reset_load_failure();
4074                }
4075            }
4076            NamedGraphsSlot::Lazy(l) => {
4077                l.failed.store(false, std::sync::atomic::Ordering::Relaxed);
4078                l.for_each_opened(|g| g.reset_load_failure());
4079            }
4080            NamedGraphsSlot::Quad(q) => q.reset_load_failures(),
4081        }
4082    }
4083
4084    /// The named-graph slot of a file opened with all its bytes in hand
4085    /// (`Rete::open`, `open_ranged`): the per-graph section decoded resident,
4086    /// or the quad index read through an owned copy of its section.
4087    fn resident(
4088        header: &Header,
4089        quad_section: impl FnOnce(u64, u64) -> Result<Vec<u8>, FileError>,
4090        per_graph: impl FnOnce() -> Result<Vec<(String, GraphIndex)>, FileError>,
4091    ) -> Result<Self, FileError> {
4092        check_named_layout(header)?;
4093        if let Some(q) = header.quad_index_section() {
4094            let bytes = quad_section(q.offset, q.length)?;
4095            return Ok(NamedGraphsSlot::Quad(crate::quad::QuadNamed::resident(
4096                bytes,
4097                header.block_codec,
4098                header.perms,
4099            )));
4100        }
4101        Ok(NamedGraphsSlot::Resident(if header.named_graphs_len > 0 {
4102            per_graph()?
4103        } else {
4104            Vec::new()
4105        }))
4106    }
4107}
4108
4109/// A read-only, in-memory view over a `.rete` file image.
4110pub struct Rete {
4111    header: Header,
4112    dict: Dictionary,
4113    index: GraphIndex,
4114    index_section_ranges: [ByteRange; NUM_PERMS],
4115    /// Per-permutation tile directories as absolute file ranges
4116    /// (`(min_a, max_a, compressed-tile range)`), for provenance. Empty for
4117    /// pre-tiling (v0.1) files.
4118    tile_ranges: [Vec<(u32, u32, ByteRange)>; NUM_PERMS],
4119    pyramid: PyramidSlot,
4120    text_index: TextIndexSlot,
4121    /// The LABEL_INDEX section (kind 8), if any.
4122    labels: SectionSlot<crate::label_index::LabelIndex>,
4123    /// The SCHEMA section (kind 9), if any.
4124    schema: SectionSlot<crate::schema_section::SchemaMeta>,
4125    named_graphs: NamedGraphsSlot,
4126    /// Raw bytes of the metadata section (empty if the file has none). The
4127    /// application layer decodes this (the CLI stores a JSON Dataset Card here).
4128    /// Only [`Rete::open`] populates it; [`Rete::open_ranged`] leaves it empty to
4129    /// preserve its minimal-fetch budget.
4130    metadata: Vec<u8>,
4131    /// Executes remote `SERVICE` blocks (SPARQL 1.1 federated query) — attached
4132    /// by the host via [`Rete::set_service_client`]; `None` means a non-SILENT
4133    /// `SERVICE` fails the query. Like the range readers, the engine never does
4134    /// I/O itself.
4135    service_client: Option<Box<dyn crate::service::ServiceClient>>,
4136    /// First failed non-SILENT `SERVICE` call of the current query. The row
4137    /// pipeline is infallible (the same contract as lazy tile fetches), so the
4138    /// failure is recorded here and taken by the top-level eval entry points.
4139    service_error: std::sync::Mutex<Option<String>>,
4140    /// Bytes a query's `COUNT(DISTINCT …)` set may hold before it spills to
4141    /// disk — see [`Rete::spill_threshold`].
4142    spill_threshold: std::sync::atomic::AtomicU64,
4143    /// The open `0x06` file (header, root, reader), for a `0x06` open.
4144    v06: Option<std::sync::Arc<crate::v06::open::V06File>>,
4145}
4146
4147impl Rete {
4148    /// Parse a full file image (v0 loads everything; a range-reading client
4149    /// will fetch only the sections it needs — same container format).
4150    pub fn open(bytes: &[u8]) -> Result<Self, FileError> {
4151        #[cfg(feature = "v06-parity")]
4152        if let Some(v6) = parity_image(bytes) {
4153            return Self::open(&v6);
4154        }
4155        if crate::v06::open::is_v06(bytes) {
4156            // A 0x06 image opens through the lazy machinery over the bytes in
4157            // memory: the same structures, faulted from RAM.
4158            let r: std::sync::Arc<dyn RangeReader + Send + Sync> =
4159                std::sync::Arc::new(crate::reader::BytesReader(std::sync::Arc::from(bytes)));
4160            let mut rete = Self::open_v06(r, bytes, false)?;
4161            rete.metadata = rete
4162                .v06
4163                .as_ref()
4164                .and_then(|f| f.card().ok().flatten())
4165                .unwrap_or_default();
4166            return Ok(rete);
4167        }
4168        let header = Header::from_bytes(bytes)?;
4169
4170        // Header offsets/lengths are untrusted (a `.rete` may be fetched truncated
4171        // or corrupt from an arbitrary URL). Slice through a checked helper so a
4172        // bad region yields an error instead of panicking on an OOB index.
4173        let region = |off: u64, len: u64| -> Result<&[u8], FileError> {
4174            checked_slice(bytes, off, len)
4175                .ok_or(FileError::Container("section range out of bounds"))
4176        };
4177
4178        let dict = decode_dictionary_container(
4179            region(header.dictionary_offset, header.dictionary_len)?,
4180            header.dict_codec,
4181        )?;
4182
4183        check_named_layout(&header)?;
4184        let index_bytes = region(header.root_dir_offset, header.root_dir_len)?;
4185        let (index, _) = decode_root_index_container(index_bytes, &header)?;
4186        let index_section_ranges = decode_index_section_ranges(
4187            index_bytes,
4188            header.root_dir_offset,
4189            header.perms,
4190            header.index_container_sections(),
4191        )?;
4192
4193        let pyramid = PyramidSlot::Resident(if header.pyramid_meta_len > 0 {
4194            Some(
4195                PyramidMeta::decode(region(header.pyramid_meta_offset, header.pyramid_meta_len)?)
4196                    .map_err(|_| FileError::Container("malformed pyramid meta"))?,
4197            )
4198        } else {
4199            None
4200        });
4201
4202        // The TEXT_INDEX section (opt-in `--text-index`); decode the whole thing
4203        // resident on a full-image open.
4204        let (ti_off, ti_len) = header.text_index_payload();
4205        let text_index = resident_text_index_slot(
4206            if ti_len > 0 {
4207                Some(region(ti_off, ti_len)?)
4208            } else {
4209                None
4210            },
4211            header.block_codec,
4212        )?;
4213        let labels = resident_label_slot(&header, &region);
4214        let schema = resident_schema_slot(&header, &region);
4215
4216        let named_graphs = NamedGraphsSlot::resident(
4217            &header,
4218            |off, len| Ok(region(off, len)?.to_vec()),
4219            || {
4220                decode_named_graphs(
4221                    region(header.named_graphs_offset, header.named_graphs_len)?,
4222                    header.block_codec,
4223                    header.perms,
4224                )
4225            },
4226        )?;
4227
4228        let metadata = if header.metadata_len > 0 {
4229            region(header.metadata_offset, header.metadata_len)?.to_vec()
4230        } else {
4231            Vec::new()
4232        };
4233
4234        let tile_ranges =
4235            tile_file_ranges(index_bytes, header.root_dir_offset, &index_section_ranges);
4236        Ok(Self {
4237            header,
4238            dict,
4239            index,
4240            index_section_ranges,
4241            tile_ranges,
4242            pyramid,
4243            text_index,
4244            labels,
4245            schema,
4246            named_graphs,
4247            metadata,
4248            service_client: None,
4249            service_error: std::sync::Mutex::new(None),
4250            spill_threshold: std::sync::atomic::AtomicU64::new(u64::MAX),
4251            v06: None,
4252        })
4253    }
4254
4255    /// Attach the client that executes `SERVICE <endpoint> { … }` blocks
4256    /// (SPARQL 1.1 federated query) against remote SPARQL endpoints. Without
4257    /// one, a non-SILENT `SERVICE` fails the query with a clear error and a
4258    /// `SERVICE SILENT` degrades to one empty solution, per the spec.
4259    pub fn set_service_client(&mut self, client: Box<dyn crate::service::ServiceClient>) {
4260        self.service_client = Some(client);
4261    }
4262
4263    pub(crate) fn service_client(&self) -> Option<&dyn crate::service::ServiceClient> {
4264        self.service_client.as_deref()
4265    }
4266
4267    /// Record a failed non-SILENT `SERVICE` call (first error wins).
4268    pub(crate) fn record_service_error(&self, msg: &str) {
4269        let mut e = self.service_error.lock().unwrap();
4270        if e.is_none() {
4271            *e = Some(msg.to_string());
4272        }
4273    }
4274
4275    /// Take (and clear) the pending `SERVICE` failure — called by every
4276    /// top-level eval entry so it can never leak into a later query.
4277    pub(crate) fn take_service_error(&self) -> Option<String> {
4278        self.service_error.lock().unwrap().take()
4279    }
4280
4281    pub fn header(&self) -> &Header {
4282        &self.header
4283    }
4284
4285    /// The file's byte layout, for visualization: header, metadata,
4286    /// dictionary, each index permutation's tile directory and individual
4287    /// tiles, pyramid summary, and named graphs — sorted by offset. Bytes not
4288    /// covered by any segment are container framing (section directories and
4289    /// length fields).
4290    pub fn file_layout(&self) -> Vec<LayoutSegment> {
4291        if let Some(f) = &self.v06 {
4292            return v06_layout(f);
4293        }
4294        let h = &self.header;
4295        let seg = |kind: &'static str, label: String, offset: u64, len: u64| LayoutSegment {
4296            kind,
4297            label,
4298            offset,
4299            len,
4300        };
4301        // The size in the label is DERIVED from the constant that defines the
4302        // span, never typed out: this line read "fixed 128 bytes" — the
4303        // pre-v0.3 header size — while emitting a 1024-byte span, and it is
4304        // what the file explorer shows a reader inspecting a real file.
4305        // `layout_header_label_matches_its_span` pins the two together.
4306        let mut out = vec![seg(
4307            "header",
4308            format!("header (fixed {} bytes)", crate::header::HEADER_LEN),
4309            0,
4310            crate::header::HEADER_LEN as u64,
4311        )];
4312        if h.metadata_len > 0 {
4313            out.push(seg(
4314                "metadata",
4315                "metadata (dataset card)".into(),
4316                h.metadata_offset,
4317                h.metadata_len,
4318            ));
4319        }
4320        out.push(seg(
4321            "dictionary",
4322            "dictionary (4 front-coded term sections)".into(),
4323            h.dictionary_offset,
4324            h.dictionary_len,
4325        ));
4326        for (si, perm) in crate::index::ALL_PERMS.into_iter().enumerate() {
4327            let sec = self.index_section_ranges[si];
4328            if sec.len == 0 {
4329                continue;
4330            }
4331            let first_tile = self.tile_ranges[si]
4332                .first()
4333                .map(|&(_, _, r)| r.offset)
4334                .unwrap_or(sec.offset + sec.len);
4335            if first_tile > sec.offset {
4336                out.push(seg(
4337                    "directory",
4338                    format!("{} tile directory", perm.name()),
4339                    sec.offset,
4340                    first_tile - sec.offset,
4341                ));
4342            }
4343            for (ti, &(min_a, max_a, r)) in self.tile_ranges[si].iter().enumerate() {
4344                out.push(seg(
4345                    "tile",
4346                    format!("{} tile {ti} (leading ids {min_a}..{max_a})", perm.name()),
4347                    r.offset,
4348                    r.len,
4349                ));
4350            }
4351        }
4352        if h.pyramid_meta_len > 0 {
4353            out.push(seg(
4354                "pyramid",
4355                "pyramid summary (communities + superedges)".into(),
4356                h.pyramid_meta_offset,
4357                h.pyramid_meta_len,
4358            ));
4359        }
4360        if h.named_graphs_len > 0 {
4361            out.push(seg(
4362                "named-graphs",
4363                format!("named graphs ({})", self.named_graphs.count()),
4364                h.named_graphs_offset,
4365                h.named_graphs_len,
4366            ));
4367        }
4368        if let Some(s) = h.label_index_section() {
4369            let label = match self.labels.get_if_loaded() {
4370                Some(li) => format!("label index ({} entries)", li.entry_count()),
4371                None => "label index".to_string(),
4372            };
4373            out.push(seg("label-index", label, s.offset, s.length));
4374        }
4375        if let Some(s) = h.schema_section() {
4376            out.push(seg(
4377                "schema",
4378                "schema (classes, query_stats, shapes)".into(),
4379                s.offset,
4380                s.length,
4381            ));
4382        }
4383        if let Some(q) = h.quad_index_section() {
4384            out.push(seg(
4385                "quad-index",
4386                format!("quad index ({} named graphs)", self.named_graphs.count()),
4387                q.offset,
4388                q.length,
4389            ));
4390        }
4391        out.sort_by_key(|s| s.offset);
4392        out
4393    }
4394
4395    /// Raw bytes of the file's metadata section, or `None` if it has none. The
4396    /// CLI stores a JSON Dataset Card here; `rete-core` treats it as opaque.
4397    /// Populated by [`Rete::open`] only — an [`Rete::open_ranged`] view returns
4398    /// `None` here (the card is not fetched on the minimal query path).
4399    pub fn metadata(&self) -> Option<&[u8]> {
4400        if self.metadata.is_empty() {
4401            None
4402        } else {
4403            Some(&self.metadata)
4404        }
4405    }
4406
4407    pub fn dictionary(&self) -> &Dictionary {
4408        &self.dict
4409    }
4410
4411    /// The pyramid metadata (summary graph + tiles), if the file has a pyramid.
4412    pub fn pyramid(&self) -> Option<&PyramidMeta> {
4413        match &self.pyramid {
4414            PyramidSlot::Resident(p) => p.as_ref(),
4415            // Faults the (possibly large) pyramid section on first access only.
4416            PyramidSlot::Lazy { loader, cell } => cell.get_or_init(loader).as_ref(),
4417        }
4418    }
4419
4420    /// The pyramid metadata **only if already resident or previously faulted** —
4421    /// never triggers a lazy range read. The query planner uses this for
4422    /// cardinality estimation so it is free for an in-memory file and never adds
4423    /// a fetch on the lazy remote path (which defers the pyramid by design).
4424    pub fn pyramid_if_loaded(&self) -> Option<&PyramidMeta> {
4425        match &self.pyramid {
4426            PyramidSlot::Resident(p) => p.as_ref(),
4427            PyramidSlot::Lazy { cell, .. } => cell.get().and_then(|o| o.as_ref()),
4428        }
4429    }
4430
4431    /// The SCHEMA section (kind 9): class hierarchy, level rollups and links,
4432    /// coherence axioms, `query_stats`, characteristic sets. Faults it in (one
4433    /// small range read) on the lazy path. `None` when the file has none — a
4434    /// file built before it existed keeps those fields in its pyramid-meta
4435    /// ([`schema_or_pyramid`](Self::schema_or_pyramid) reads either).
4436    pub fn schema(&self) -> Option<&crate::schema_section::SchemaMeta> {
4437        self.schema.get()
4438    }
4439
4440    /// The schema fields from the SCHEMA section when the file has one, else
4441    /// from the pyramid-meta (faulting either on the lazy path). `None` when
4442    /// the file carries neither.
4443    pub fn schema_or_pyramid(
4444        &self,
4445    ) -> Option<std::borrow::Cow<'_, crate::schema_section::SchemaMeta>> {
4446        if let Some(s) = self.schema() {
4447            return Some(std::borrow::Cow::Borrowed(s));
4448        }
4449        self.pyramid()
4450            .map(|p| std::borrow::Cow::Owned(crate::schema_section::SchemaMeta::from_pyramid(p)))
4451    }
4452
4453    /// Per-predicate planner statistics (`query_stats`): from the SCHEMA
4454    /// section (faulting it on the lazy path — it is small), else from the
4455    /// pyramid-meta when that is resident. Empty when the file has neither.
4456    /// See [`crate::meta::PredStat`].
4457    pub fn predicate_stats(&self) -> &[crate::meta::PredStat] {
4458        if let Some(s) = self.schema().filter(|s| !s.predicate_stats.is_empty()) {
4459            return &s.predicate_stats;
4460        }
4461        self.pyramid_if_loaded()
4462            .map(|p| p.predicate_stats.as_slice())
4463            .unwrap_or(&[])
4464    }
4465
4466    /// The statistics the planner may use **without a read it was not going to
4467    /// make**: the SCHEMA section if it is resident or already faulted — or,
4468    /// with `fault`, after faulting it (one small range, once per session) —
4469    /// else the resident pyramid-meta's block.
4470    pub(crate) fn planner_stats(&self, fault: bool) -> &[crate::meta::PredStat] {
4471        let schema = if fault {
4472            self.schema.get()
4473        } else {
4474            self.schema.get_if_loaded()
4475        };
4476        if let Some(s) = schema.filter(|s| !s.predicate_stats.is_empty()) {
4477            return &s.predicate_stats;
4478        }
4479        self.pyramid_if_loaded()
4480            .map(|p| p.predicate_stats.as_slice())
4481            .unwrap_or(&[])
4482    }
4483
4484    /// The entity shapes (characteristic sets): from the SCHEMA section, else
4485    /// from a resident pyramid-meta. See [`crate::meta::CharSet`].
4486    pub fn char_sets(&self) -> &[crate::meta::CharSet] {
4487        if let Some(s) = self.schema().filter(|s| !s.char_sets.is_empty()) {
4488            return &s.char_sets;
4489        }
4490        self.pyramid_if_loaded()
4491            .map(|p| p.char_sets.as_slice())
4492            .unwrap_or(&[])
4493    }
4494
4495    /// The **legacy** label block of a pyramid-meta (≤ 8,192 entries) — empty
4496    /// when the file has none or the pyramid isn't resident. Files built with
4497    /// `--label-index` carry a LABEL_INDEX section instead; see
4498    /// [`label_index_section`](Self::label_index_section). See
4499    /// [`crate::meta::LabelEntry`].
4500    pub fn label_index(&self) -> &[crate::meta::LabelEntry] {
4501        self.pyramid_if_loaded()
4502            .map(|p| p.label_index.as_slice())
4503            .unwrap_or(&[])
4504    }
4505
4506    /// Whether the file carries a LABEL_INDEX section (kind 8). Header only.
4507    pub fn has_label_index(&self) -> bool {
4508        self.header.label_index_section().is_some()
4509    }
4510
4511    /// The LABEL_INDEX section, faulting its head in on the lazy path.
4512    pub fn label_index_section(&self) -> Option<&crate::label_index::LabelIndex> {
4513        self.labels.get()
4514    }
4515
4516    /// Prefix-search the labels: the subjects whose label starts with `prefix`
4517    /// (case-insensitive), as `(label, subject term)`, capped at `limit`.
4518    /// Reads the LABEL_INDEX section when the file has one (every labelled
4519    /// subject, no dictionary read); otherwise the legacy pyramid-meta block,
4520    /// which **faults the pyramid** on the lazy path. Empty when the file
4521    /// carries neither.
4522    pub fn prefix_search(&self, prefix: &str, limit: usize) -> Vec<(String, String)> {
4523        if let Some(li) = self.labels.get() {
4524            return li.prefix_search(prefix, limit);
4525        }
4526        let Some(pyr) = self.pyramid() else {
4527            return Vec::new();
4528        };
4529        resolve_label_hits(&self.dict, pyr.prefix_search(prefix, limit))
4530    }
4531
4532    /// The full-text index (TEXT_INDEX section), faulting it in on first access
4533    /// on the lazy remote path. `None` when the file carries no text index.
4534    pub(crate) fn text_index(&self) -> Option<&crate::text_index::TextIndex> {
4535        self.text_index.index()
4536    }
4537
4538    /// Whether this file carries a full-text (TEXT_INDEX) section, i.e. it was
4539    /// built with `--text-index`. Cheap — reads the header, never faults.
4540    pub fn has_text_index(&self) -> bool {
4541        self.header.text_index_len > 0
4542    }
4543
4544    /// What the full-text index covers (graphs, predicates) — `None` when the
4545    /// file has none. Faults the token table on the lazy remote path, as a
4546    /// search does; the scope block lives in it.
4547    pub fn text_index_scope(&self) -> Option<crate::text_index::TextIndexScope> {
4548        self.text_index().map(|ti| ti.scope().clone())
4549    }
4550
4551    /// Byte length of the TEXT_INDEX section's leading **token table** — the
4552    /// length varint plus the compressed table, which is exactly what a first
4553    /// [`text_search`](Self::text_search) faults. `None` when the file carries
4554    /// no text index (or its head could not be read).
4555    ///
4556    /// This is the number to quote as the cost of a first search, NOT
4557    /// `header().text_index_len`: that counts the postings blob too, which is
4558    /// the bulk of the section and is only ever fetched one posting list at a
4559    /// time. On the published `epfl-infoscience.rete` the section is 195 MB and
4560    /// its token table 29 MB — a 6.5× difference.
4561    ///
4562    /// Costs nothing on a resident open (measured while the bytes were in hand);
4563    /// a lazy/ranged open pays one ≤10-byte range read, memoized, and still
4564    /// never faults the table itself.
4565    pub fn text_index_token_table_len(&self) -> Option<u64> {
4566        self.text_index.token_table_len()
4567    }
4568
4569    /// Full-text search over the literals: subject IRIs that carry **every** word
4570    /// in `words` (whole-word, case-insensitive — AND semantics), optionally also
4571    /// requiring a word that **starts with** `prefix` (token-prefix). Results are
4572    /// ordered by subject id and capped at `limit` (0 = uncapped). Empty when the
4573    /// file has no text index or nothing matches.
4574    ///
4575    /// Like [`prefix_search`](Self::prefix_search), this **faults** the index on
4576    /// the lazy remote path — a search is an explicit read, and only the queried
4577    /// posting lists are fetched, not the whole index.
4578    pub fn text_search(&self, words: &[&str], prefix: Option<&str>, limit: usize) -> Vec<String> {
4579        text_search_in(self.text_index(), &self.dict, words, prefix, limit)
4580    }
4581
4582    /// The default-graph permutation index.
4583    pub fn default_index(&self) -> &GraphIndex {
4584        &self.index
4585    }
4586
4587    /// Resolve every triple of a graph (`None` = default graph) back to terms.
4588    pub fn dump(&self, graph: Option<&str>) -> Vec<TermTriple> {
4589        let index = match graph {
4590            None => &self.index,
4591            Some(g) => match self.graph_index(g) {
4592                Some(i) => i,
4593                None => return Vec::new(),
4594            },
4595        };
4596        index
4597            .match_pattern((None, None, None))
4598            .into_iter()
4599            .filter_map(|(s, p, o)| {
4600                Some((
4601                    self.dict.subject_term(s)?,
4602                    self.dict.predicate_term(p)?,
4603                    self.dict.object_term(o)?,
4604                ))
4605            })
4606            .collect()
4607    }
4608
4609    /// Stream every triple of a graph (`None` = default) to `f`, resolving terms
4610    /// a batch at a time — no full `Vec` materialization, so it is safe on graphs far
4611    /// larger than RAM. `rete export` uses this to serialize 100M+ triple files
4612    /// that `dump()` (which collects every term into a `Vec<String>`) would OOM on.
4613    ///
4614    /// On a lazy/ranged open the dictionary is **not** prefetched whole: each
4615    /// batch faults only the chunks its own terms live in. Dumping ONE named
4616    /// graph therefore costs that graph's terms, not the file's. Dumping
4617    /// *everything* still reads every dictionary byte and leaves every chunk
4618    /// resident — that is inherent, and it, not the prefetch, is what sets the
4619    /// peak of a full export. See the comment in the body for both measurements.
4620    ///
4621    /// To dump a *slice* — one predicate, one subject, one object — call
4622    /// [`dump_filtered_each`](Self::dump_filtered_each), which is this method
4623    /// with a triple pattern and prunes tiles instead of filtering rows.
4624    pub fn dump_each<F: FnMut(&str, &str, &str)>(&self, graph: Option<&str>, f: F) {
4625        self.dump_filtered_each(graph, None, None, None, f)
4626    }
4627
4628    /// [`dump_each`](Self::dump_each) restricted to a triple **pattern** — the
4629    /// filtered dump. Any of `s` / `p` / `o` may be bound (canonical N-Triples
4630    /// term tokens); all three unbound is exactly `dump_each`.
4631    ///
4632    /// # Why this is cheap, and how much
4633    ///
4634    /// It is the same access path a routed *query* takes, not a full scan with a
4635    /// predicate test bolted on: [`GraphIndex::scan_iter`] picks the permutation
4636    /// with the longest bound prefix, binary-searches that section's tile
4637    /// directory down to the span a bound leading id can live in, and then drops
4638    /// every remaining tile whose recorded synopsis proves it cannot hold a
4639    /// bound secondary component — **all from directories, without fetching a
4640    /// tile**. So a filtered dump pays for the slice, which is the whole point
4641    /// of issue #117.
4642    ///
4643    /// Measured on `cordis.rete` (801 MB, a 417 MB dictionary, 26.4M quads,
4644    /// six named graphs), lazily opened; "before" is the only way to get a
4645    /// predicate slice out of a dump previously — dump the graph and throw away
4646    /// what does not match, in the consumer:
4647    ///
4648    /// ```text
4649    ///   graph=results  p=s66#doi     -> 337,811 rows
4650    ///     before  375.8 MB · 1797 req · 2105.8 MB RSS · 12.8 s · first row 409 ms
4651    ///     after    16.0 MB ·  105 req ·  182.6 MB RSS ·  0.41 s · first row  14 ms
4652    ///   graph=results  p=s66#isbn    ->  40,761 rows
4653    ///     before  375.8 MB · 1797 req · 2105.7 MB RSS · 12.8 s
4654    ///     after    15.5 MB ·  104 req ·  155.2 MB RSS ·  0.23 s
4655    /// ```
4656    ///
4657    /// # Where it does NOT help
4658    ///
4659    /// The index is pruned; the **dictionary is not**. Term resolution still
4660    /// faults whichever chunks the surviving rows' terms live in, and on a file
4661    /// whose payload *is* the literals those chunks are most of the file. Same
4662    /// file, a predicate whose objects are the long abstracts:
4663    ///
4664    /// ```text
4665    ///   graph=projects p=s66#abstract ->  80,206 rows
4666    ///     before  260.7 MB · 1142 req · 1496.9 MB RSS · 5.97 s
4667    ///     after   213.4 MB · 1606 req · 1144.4 MB RSS · 2.91 s   (1.2x, not 23x)
4668    /// ```
4669    ///
4670    /// An *unfiltered* dump is unchanged by construction — it is the floor, and
4671    /// its peak is the resident dictionary, not this. Two ceilings sit above
4672    /// both: a faulted dictionary chunk is a `OnceCell` nothing ever evicts, and
4673    /// on a literal-heavy file the chunk directory alone can be a third of the
4674    /// file before any dump work at all (#198).
4675    ///
4676    /// # Permutations
4677    ///
4678    /// Every one of the eight bound/unbound shapes routes inside
4679    /// [`PermSet::CORE`] — `{SPO, POS, OSP}`, the minimum a legal file carries
4680    /// (see `perm_routing_never_leaves_core`). A file built with
4681    /// `--permutations 3` therefore prunes *identically* to a six-permutation
4682    /// one: same tiles, same rows, no fallback path to get wrong.
4683    ///
4684    /// A bound term the dictionary does not know, or an absent graph IRI, yields
4685    /// nothing without touching the index.
4686    ///
4687    /// # Order
4688    ///
4689    /// Rows arrive in the **routed permutation's** order, which is the price of
4690    /// routing at all. Unfiltered (and subject-bound) that is SPO — canonical
4691    /// `(s, p, o)`, unchanged from [`dump_each`](Self::dump_each). A bound
4692    /// predicate routes to POS and streams `(p, o, s)`; a bound object routes to
4693    /// OSP. The *set* is identical to the unfiltered dump filtered; the order is
4694    /// not, so a consumer that needs canonical order must sort. N-Quads, the
4695    /// format `rete export` writes, is order-independent.
4696    ///
4697    /// [`PermSet::CORE`]: crate::index::PermSet::CORE
4698    pub fn dump_filtered_each<F: FnMut(&str, &str, &str)>(
4699        &self,
4700        graph: Option<&str>,
4701        s: Option<&str>,
4702        p: Option<&str>,
4703        o: Option<&str>,
4704        mut f: F,
4705    ) {
4706        let index = match graph {
4707            None => Some(&self.index),
4708            Some(g) => self.graph_index(g),
4709        };
4710        let Some(index) = index else { return };
4711        // A bound term the dictionary does not know can match nothing, and the
4712        // index is never touched — same contract as `query_iter`/`query_batch`.
4713        let Some(pattern) = self.resolve_query_pattern(s, p, o) else {
4714            return;
4715        };
4716        let dict = &self.dict;
4717        // Resolve in fixed-size batches with ONE coalesced dictionary fault per
4718        // batch — the same `prefetch_terms` call `dump_batch` makes, for the
4719        // same reason.
4720        //
4721        // This used to be `Dictionary::prefetch_all` up front, on the argument
4722        // that a full dump reaches every term anyway so the bytes are owed
4723        // either way. That argument only holds for a dump of EVERYTHING, and
4724        // this takes a graph AND a pattern. Measured on `cordis.rete` (801 MB,
4725        // a 417 MB dictionary, six named graphs), lazily opened:
4726        //
4727        //   dump_each(Some(fundingschemes))  417 MB read, 2510 MB peak RSS
4728        //                              →     173 MB read,  619 MB peak RSS
4729        //   dump_each(None) — the EMPTY default graph, which is the first thing
4730        //   `rete export` calls on a quads file:
4731        //                                    415 MB read, 2508 MB peak RSS
4732        //                              →       0 MB read,   95 MB peak RSS
4733        //
4734        // The 2.5 GB comes from asking for every chunk at once: the bulk loader
4735        // coalesces adjacent chunks into one span, so the whole dictionary
4736        // section is materialized three times over before the first triple —
4737        // the fetched span, the per-chunk copies sliced out of it, and the
4738        // decompressed bodies.
4739        //
4740        // Be clear about what this does NOT buy. A dump of the whole default
4741        // graph still faults every chunk, and chunks stay resident once
4742        // faulted, so `rete export` of a single-graph file is unchanged:
4743        // `figshare.rete` (222 MB, a 126 MB dictionary) peaks at 874 MB lazily
4744        // either way. The resident dictionary is the floor there, not this.
4745        //
4746        // Within each window, term resolution is CHUNK-ORDERED, not row-ordered
4747        // (`Dictionary::resolve_window`, bounded-export phase 1): the window's
4748        // ids are grouped by the `(section, chunk)` they live in and each chunk
4749        // is decoded once and its runs walked once, filling every slot that
4750        // chunk owns, then the rows are emitted in the original scan order. Two
4751        // things follow. First, a window's objects — which scatter across the
4752        // object-only section for one subject — touch each chunk a single time
4753        // instead of re-faulting it per row. Second, the per-id re-walk from a
4754        // run's start that the old row-order path did for *every* id collapses
4755        // to one monotonic forward pass per run. The output is byte-for-byte
4756        // identical (same bytes per id, same emission order, same
4757        // all-three-resolved emit rule); the cap is still unlimited, so this is
4758        // structural preparation for the real bound (phase 2), plus a small
4759        // free speedup. The coalesced bulk fault (`prefetch_terms`) is
4760        // unchanged, so the read pattern and peak residency are as before.
4761        const WINDOW: usize = 4096;
4762        // The whole-window coalesced prefetch (`prefetch_terms`) materializes
4763        // every distinct chunk the window touches at once — up to ~2·WINDOW
4764        // chunks for scattered objects. Under a GENEROUS budget that transient
4765        // fits and the coalesced reads are the fast path (small/normal files,
4766        // `--in-memory`, unlimited). Under a TIGHT budget it would blow the cap
4767        // and thrash (insert-then-evict every chunk, then re-fault it in
4768        // `resolve_window`), so skip it: `resolve_window` is chunk-ordered and
4769        // holds one chunk `Arc` at a time (plan §3.5), keeping the window's
4770        // working set to a single chunk regardless of how the objects scatter.
4771        // Output is byte-identical either way — prefetch only warms the cache.
4772        let cap = dict.cache_cap();
4773        let prefetch_window =
4774            cap == u64::MAX || cap >= (WINDOW as u64) * 2 * (DICT_CHUNK_BUDGET as u64);
4775        // Under a finite budget the prefetch also goes in batches of
4776        // PREFETCH_BATCH chunks: one bulk read holds everything it fetched
4777        // (span, slices, decoded bodies) until it is all cached, which for a
4778        // scattered window was thousands of chunks — up to ~1 GB on
4779        // openaire-relation — outside the cache cap. Unlimited keeps one read
4780        // per section (the latency-optimal shape for a remote file).
4781        const PREFETCH_BATCH: usize = 64;
4782        let prefetch_batch = if cap == u64::MAX {
4783            usize::MAX
4784        } else {
4785            PREFETCH_BATCH
4786        };
4787        let mut ids: Vec<(u32, u32, u32)> = Vec::with_capacity(WINDOW);
4788        let mut nodes: Vec<u32> = Vec::with_capacity(WINDOW * 2);
4789        let mut preds: Vec<u32> = Vec::with_capacity(WINDOW);
4790        let mut resolver = crate::dictionary::WindowResolver::default();
4791        let mut flush = |ids: &mut Vec<(u32, u32, u32)>,
4792                         resolver: &mut crate::dictionary::WindowResolver,
4793                         f: &mut F| {
4794            if ids.is_empty() {
4795                return;
4796            }
4797            // Coalesced bulk fault of exactly the chunks this window needs —
4798            // only under a generous budget (see `prefetch_window` above); a
4799            // tight budget faults chunk-by-chunk inside `resolve_window`.
4800            if prefetch_window {
4801                nodes.clear();
4802                preds.clear();
4803                for &(s, p, o) in ids.iter() {
4804                    nodes.push(dict.subject_node(s));
4805                    nodes.push(dict.object_node(o));
4806                    preds.push(p);
4807                }
4808                dict.prefetch_terms_batched(&nodes, &preds, prefetch_batch);
4809            }
4810            dict.resolve_window(ids, resolver, |s, p, o| f(s, p, o));
4811            ids.clear();
4812        };
4813        for t in index.scan_iter(pattern) {
4814            ids.push(t);
4815            if ids.len() == WINDOW {
4816                flush(&mut ids, &mut resolver, &mut f);
4817            }
4818        }
4819        flush(&mut ids, &mut resolver, &mut f);
4820    }
4821
4822    /// What a [`dump_filtered_each`](Self::dump_filtered_each) over this graph
4823    /// and pattern will fetch, **before** it starts — the dump twin of
4824    /// `rete cost`'s query preview.
4825    ///
4826    /// Costed from the tile directories, so it fetches no tile. It does read the
4827    /// dictionary far enough to resolve the bound terms (that is a real,
4828    /// unavoidable cost of the dump too) and to learn whether the graph exists.
4829    pub fn dump_plan(
4830        &self,
4831        graph: Option<&str>,
4832        s: Option<&str>,
4833        p: Option<&str>,
4834        o: Option<&str>,
4835    ) -> DumpPlan {
4836        let index = match graph {
4837            None => Some(&self.index),
4838            Some(g) => self.graph_index(g),
4839        };
4840        let scan = index
4841            .zip(self.resolve_query_pattern(s, p, o))
4842            .map(|(ix, pattern)| ix.scan_plan(pattern));
4843        DumpPlan {
4844            scan,
4845            dictionary_bytes: match &self.v06 {
4846                Some(f) => f.section(crate::v06::kind::DICT_DATA).map_or(0, |r| r.len),
4847                None => self.header.dictionary_len,
4848            },
4849        }
4850    }
4851
4852    /// The **pull** twin of [`Rete::dump_each`](Self::dump_each): every triple of a
4853    /// graph (`None` = default) as a lazy iterator of resolved terms.
4854    ///
4855    /// Same constant-memory scan — one triple decoded and resolved per `next()`,
4856    /// never a `Vec` of the whole graph — but the caller drives it, so it can
4857    /// stop early or be **suspended and resumed** across a foreign-function
4858    /// boundary. That is what the wasm/JS client's batched quad cursor needs:
4859    /// a callback cannot be paused mid-scan to hand control back to JavaScript,
4860    /// an iterator can.
4861    ///
4862    /// The dictionary is NOT prefetched: terms fault in as they are resolved, so
4863    /// stopping after a handful of triples costs a handful of dictionary reads
4864    /// rather than the whole dictionary — which on a lazy remote open of a large
4865    /// file would be gigabytes before the first triple, defeating the point of a
4866    /// pull API. [`dump_each`](Self::dump_each) does not prefetch it whole
4867    /// either; it batches the same per-term faults. Peak memory is
4868    /// O(faulted dictionary chunks + faulted index tiles), *not* O(triples).
4869    pub fn dump_iter(&self, graph: Option<&str>) -> impl Iterator<Item = TermTriple> + '_ {
4870        self.query_iter(graph, None, None, None)
4871    }
4872
4873    /// [`dump_iter`](Self::dump_iter) generalized to a triple **pattern**: the
4874    /// lazy pull form of [`query_in_graph`](Self::query_in_graph). A bound term
4875    /// unknown to the dictionary, or an unknown graph IRI, yields nothing.
4876    ///
4877    /// Same constant-memory guarantee — one triple decoded and resolved per
4878    /// `next()`, never a `Vec` of every match — and the same absence of a
4879    /// dictionary prefetch, so stopping after a handful of matches costs a
4880    /// handful of dictionary reads. `query_in_graph` is the eager twin; prefer
4881    /// this one wherever the consumer can stop early (`ASK`, `LIMIT`, an RDF4J
4882    /// `getStatements` that is about to be sliced).
4883    pub fn query_iter(
4884        &self,
4885        graph: Option<&str>,
4886        s: Option<&str>,
4887        p: Option<&str>,
4888        o: Option<&str>,
4889    ) -> impl Iterator<Item = TermTriple> + '_ {
4890        let index = match graph {
4891            None => Some(&self.index),
4892            Some(g) => self.graph_index(g),
4893        };
4894        index
4895            .zip(self.resolve_query_pattern(s, p, o))
4896            .into_iter()
4897            .flat_map(|(ix, pattern)| ix.scan_iter(pattern))
4898            .filter_map(move |(s, p, o)| {
4899                Some((
4900                    self.dict.subject_term(s)?,
4901                    self.dict.predicate_term(p)?,
4902                    self.dict.object_term(o)?,
4903                ))
4904            })
4905    }
4906
4907    /// One bounded, **resumable** slice of a pattern's matches inside one graph
4908    /// (`None` = default): `(triples, next_cursor, done)`. Start at `cursor = 0`
4909    /// and feed the returned cursor back until `done`.
4910    ///
4911    /// This is [`query_in_graph`](Self::query_in_graph) made streamable across a
4912    /// boundary that cannot hold a Rust borrow — the wasm/JVM one. The engine
4913    /// keeps no iterator alive between calls; the entire resume state is the
4914    /// opaque `u64` (see [`GraphIndex::scan_batch`]), so a client's cursor
4915    /// survives being suspended, and a cursor that is abandoned leaks nothing
4916    /// but a `u64`.
4917    ///
4918    /// The dictionary is faulted **per batch**, not whole: taking ten matches
4919    /// off the front of a 9.8-billion-quad file reads ten matches' worth of
4920    /// terms. That is the difference between `LIMIT 1` answering in bounded time
4921    /// and it reading a 30 GB dictionary first.
4922    ///
4923    /// `max_quads` is a floor (batches end on an a-group boundary), and every
4924    /// call either returns at least one row or reports `done`.
4925    pub fn query_batch(
4926        &self,
4927        graph: Option<&str>,
4928        s: Option<&str>,
4929        p: Option<&str>,
4930        o: Option<&str>,
4931        cursor: u64,
4932        max_quads: usize,
4933    ) -> (Vec<TermTriple>, u64, bool) {
4934        let index = match graph {
4935            None => &self.index,
4936            // An unknown graph IRI is an empty scan, not an error — same as
4937            // `query_in_graph` and `dump_batch`.
4938            Some(g) => match self.graph_index(g) {
4939                Some(i) => i,
4940                None => return (Vec::new(), cursor, true),
4941            },
4942        };
4943        // A bound term the dictionary does not know can match nothing, and the
4944        // index is never touched.
4945        let Some(pattern) = self.resolve_query_pattern(s, p, o) else {
4946            return (Vec::new(), cursor, true);
4947        };
4948        let (ids, next, done) = index.scan_batch(pattern, cursor, max_quads);
4949        // One coalesced dictionary fault for THIS batch — the same call
4950        // `query_in_graph` and `dump_batch` make, and the reason a partial walk
4951        // costs in proportion to what it actually read.
4952        if !ids.is_empty() {
4953            let mut nodes = Vec::with_capacity(ids.len() * 2);
4954            let mut preds = Vec::with_capacity(ids.len());
4955            for &(s, p, o) in &ids {
4956                nodes.push(self.dict.subject_node(s));
4957                nodes.push(self.dict.object_node(o));
4958                preds.push(p);
4959            }
4960            self.dict.prefetch_terms(&nodes, &preds);
4961        }
4962        let triples = ids
4963            .into_iter()
4964            .filter_map(|(s, p, o)| {
4965                Some((
4966                    self.dict.subject_term(s)?,
4967                    self.dict.predicate_term(p)?,
4968                    self.dict.object_term(o)?,
4969                ))
4970            })
4971            .collect();
4972        (triples, next, done)
4973    }
4974
4975    /// Resolve one bounded slice of a graph's triples. Returns
4976    /// `(triples, next_cursor, done)`; start with `cursor = 0` and feed the
4977    /// returned cursor back until `done`.
4978    ///
4979    /// This is the PULL half of the dump, and it exists because [`Rete::dump_each`] is
4980    /// push-based: it drives the scan itself and hands each triple to a
4981    /// callback. A client that wants to pull (a Python generator, a JS async
4982    /// iterator) has to hold the scan's stack between calls — which means a
4983    /// thread (unavailable on Pyodide and in wasm) or a self-referential struct
4984    /// holding an iterator that borrows the `Rete` stored next to it, whose
4985    /// soundness rests on drop order plus an unstated promise that this crate's
4986    /// lazily-faulted caches stay write-once. Neither is worth it for a dump.
4987    ///
4988    /// Instead the scan is made RESUMABLE: SPO tiles are ordered by subject id,
4989    /// so "every triple of subject `sid`, for ascending `sid`" visits exactly the
4990    /// tiles a full scan visits, in the same order, and the whole resume state
4991    /// collapses to one `u32`. No thread, no `unsafe`, no borrow held across
4992    /// calls — and unlike `(offset, limit)` it is O(n) overall rather than
4993    /// O(n²/limit), because nothing is ever re-scanned.
4994    ///
4995    /// `max_quads` is the FLOOR of a batch, not a hard cut: a batch always ends
4996    /// on a subject boundary, so no subject is split across two calls. Work per
4997    /// call is bounded by a probe budget too — a sparse named graph whose few
4998    /// subjects are spread over a huge id space returns early with `done = false`
4999    /// rather than grinding through absent ids, and the caller just asks again.
5000    pub fn dump_batch(
5001        &self,
5002        graph: Option<&str>,
5003        cursor: u32,
5004        max_quads: usize,
5005    ) -> (Vec<TermTriple>, u32, bool) {
5006        /// The first subject id at or after `sid` that any SPO tile can hold, or
5007        /// `None` when `sid` is past the last tile. Tiles ascend by leading
5008        /// range, so this is a binary search; it returns `sid` itself when a tile
5009        /// already covers it (an ordinary "this subject has no triples" step).
5010        fn next_tile_start(tiles: &crate::index::TileList, sid: u32) -> Option<u32> {
5011            let i = tiles.first_max_ge(sid);
5012            (i < tiles.len()).then(|| tiles[i].leading_range().0.max(sid))
5013        }
5014
5015        let index = match graph {
5016            None => self.default_index(),
5017            // An unknown graph IRI is an empty dump, not an error — same as
5018            // `dump_each`.
5019            Some(g) => match self.graph_index(g) {
5020                Some(i) => i,
5021                None => return (Vec::new(), cursor, true),
5022            },
5023        };
5024        // Tile leading ranges live in the section directory, so both the span and
5025        // the gap jumps below are free on a lazy/remote open — no tile is fetched
5026        // to decide where to look.
5027        let tiles = index.tile_sections()[IndexPermutation::Spo.section_index()];
5028        let (Some(first), Some(last)) = (
5029            tiles.first().map(|t| t.leading_range().0),
5030            tiles.last().map(|t| t.leading_range().1),
5031        ) else {
5032            return (Vec::new(), cursor, true); // empty graph
5033        };
5034
5035        let mut sid = cursor.max(first);
5036        let mut probes = max_quads.saturating_mul(4).max(1 << 16);
5037        let mut ids: Vec<(u32, u32, u32)> = Vec::new();
5038        let mut exhausted = false;
5039        while sid <= last && probes > 0 {
5040            probes -= 1;
5041            let hits = index.match_pattern((Some(sid), None, None));
5042            if hits.is_empty() {
5043                // No triples at this id: jump to the next subject the index
5044                // holds (the tile's group directory knows it), instead of
5045                // stepping through absent ids one at a time. That is what keeps
5046                // a quad-index view — whose tile shared with other graphs has
5047                // the conservative range `0..=u32::MAX` — from probing four
5048                // billion ids. Only when that lookup cannot answer (a failed
5049                // fetch, already recorded) does it fall back to the directory:
5050                // if the id falls in a hole BETWEEN tiles, jump to the next
5051                // tile's first id.
5052                match index.next_leading(IndexPermutation::Spo.section_index(), sid) {
5053                    Some(next) if next > sid => {
5054                        sid = next;
5055                        continue;
5056                    }
5057                    None if !index.load_incomplete() => {
5058                        exhausted = true;
5059                        break;
5060                    }
5061                    _ => {}
5062                }
5063                match next_tile_start(tiles, sid) {
5064                    None => {
5065                        exhausted = true;
5066                        break;
5067                    }
5068                    Some(next) if next > sid => {
5069                        sid = next;
5070                        continue;
5071                    }
5072                    Some(_) => {}
5073                }
5074            } else {
5075                ids.extend(hits);
5076            }
5077            if sid == u32::MAX {
5078                exhausted = true;
5079                break;
5080            }
5081            sid += 1;
5082            if ids.len() >= max_quads {
5083                break;
5084            }
5085        }
5086        let done = exhausted || sid > last;
5087
5088        // One coalesced dictionary fault for this batch, then resolve.
5089        // Deliberately NOT `Dictionary::prefetch_all` (what `dump_each` uses):
5090        // on a lazy open that pulls the ENTIRE dictionary — gigabytes on a big
5091        // file — before the first quad, so taking five quads off the front would
5092        // cost as much as a full dump. Per-batch prefetch keeps a partial walk
5093        // proportional to what it actually read.
5094        let dict = self.dictionary();
5095        if !ids.is_empty() {
5096            let mut nodes = Vec::with_capacity(ids.len() * 2);
5097            let mut preds = Vec::with_capacity(ids.len());
5098            for &(s, p, o) in &ids {
5099                nodes.push(dict.subject_node(s));
5100                nodes.push(dict.object_node(o));
5101                preds.push(p);
5102            }
5103            dict.prefetch_terms(&nodes, &preds);
5104        }
5105        let triples = ids
5106            .into_iter()
5107            .filter_map(|(s, p, o)| {
5108                Some((
5109                    dict.subject_term(s)?,
5110                    dict.predicate_term(p)?,
5111                    dict.object_term(o)?,
5112                ))
5113            })
5114            .collect();
5115        (triples, sid, done)
5116    }
5117
5118    /// How many named graphs this dataset has. On a lazy ranged open this
5119    /// reads only the section's leading count varint — never the graphs.
5120    pub fn named_graph_count(&self) -> usize {
5121        self.named_graphs.count()
5122    }
5123
5124    /// The `i`-th named graph's quad count, when the file records it: a
5125    /// quad-index file keeps it in its graph directory, so this reads one
5126    /// directory block and never the graph's index. `None` for a per-graph
5127    /// (NAMED_GRAPHS section) file, or past the last graph.
5128    pub fn named_graph_quad_count(&self, i: usize) -> Option<u64> {
5129        match &self.named_graphs {
5130            NamedGraphsSlot::Quad(q) => q.quad_count_at(i),
5131            _ => None,
5132        }
5133    }
5134
5135    /// The `i`-th named graph as an **owned** index that is not memoised,
5136    /// when the file can make one cheaply: a quad-index file's graph of at
5137    /// most [`TRANSIENT_GRAPH_QUADS`] quads. A walk over every graph (`GRAPH
5138    /// ?g`, the union default graph, `query_quads`) evaluates each such graph
5139    /// and drops it, instead of keeping one index per graph for the life of
5140    /// the open — 1.5 M of them on the OpenCitations sample. `None` for a
5141    /// larger graph, a per-graph file, or a failed read: use
5142    /// [`named_graph_at`](Self::named_graph_at) then.
5143    pub fn named_graph_transient(&self, i: usize) -> Option<GraphIndex> {
5144        match &self.named_graphs {
5145            NamedGraphsSlot::Quad(q) => q.transient_view(i, TRANSIENT_GRAPH_QUADS),
5146            _ => None,
5147        }
5148    }
5149
5150    /// The ordinals (ascending, as [`named_graph_at`](Self::named_graph_at)
5151    /// counts them) of the named graphs holding at least one quad whose
5152    /// subject is one of `subjects` (subject ids), for a quad-index file: one
5153    /// sequential pass over its graph-leading SPO tiles, never a view per
5154    /// graph. `None` for a per-graph file, which has no such pass, or on a
5155    /// failed read (recorded, see [`index_incomplete`](Self::index_incomplete)).
5156    pub(crate) fn named_graphs_with_subjects(
5157        &self,
5158        subjects: &std::collections::BTreeSet<u32>,
5159    ) -> Option<Vec<usize>> {
5160        match &self.named_graphs {
5161            NamedGraphsSlot::Quad(q) => q.graphs_with_subjects(subjects),
5162            _ => None,
5163        }
5164    }
5165
5166    /// Deduplicated quads across every named graph, when the file records it
5167    /// (a quad-index file, from its preamble: one small read). `None` for a
5168    /// per-graph file — there it is the header's `quad_count` minus the
5169    /// default graph's triples.
5170    pub fn named_quad_count(&self) -> Option<u64> {
5171        match &self.named_graphs {
5172            NamedGraphsSlot::Quad(q) => Some(q.quad_count()),
5173            _ => None,
5174        }
5175    }
5176
5177    /// How many triples `index` holds, for sizing a pattern's tile estimate:
5178    /// a named graph's own count when it is known without a tile read (a
5179    /// quad-index graph's directory count, a resident per-graph index), else
5180    /// the header's total. The default graph keeps the total: its size is not
5181    /// recorded in the header, and the planner's thresholds were tuned on it.
5182    pub(crate) fn index_triples(&self, index: &GraphIndex) -> u64 {
5183        index.size_hint().unwrap_or(self.header().quad_count)
5184    }
5185
5186    /// The `i`-th named graph's IRI (stored order). On a lazy ranged open this
5187    /// walks entry HEADERS up to `i` — it never decodes a graph's index.
5188    pub fn named_graph_name_at(&self, i: usize) -> Option<&str> {
5189        self.named_graphs.name_at(&self.dict, i, false)
5190    }
5191
5192    /// The `i`-th named graph as `(iri, index)`. On a lazy ranged open the
5193    /// index is fetched and decoded on first access and memoised; check
5194    /// [`index_incomplete`](Self::index_incomplete) after evaluating.
5195    pub fn named_graph_at(&self, i: usize) -> Option<(&str, &GraphIndex)> {
5196        self.named_graphs.graph_at(&self.dict, i, false)
5197    }
5198
5199    /// The `i`-th graph's name as an owned string that the file does **not**
5200    /// keep: what a walk over millions of graphs uses, so the names it has
5201    /// passed are not all held at once. `exhaustive = true` tells a lazy walk
5202    /// the caller will visit every graph, so it may read the section in bulk
5203    /// chunks; purely a fetch strategy, results are identical either way. Only
5204    /// the SPARQL evaluator sets it, from shapes where full consumption is
5205    /// provable.
5206    pub(crate) fn named_graph_name_owned_demand(
5207        &self,
5208        i: usize,
5209        exhaustive: bool,
5210    ) -> Option<String> {
5211        self.named_graphs.name_owned(&self.dict, i, exhaustive)
5212    }
5213
5214    /// The ordinal of the quad-index graph named `iri`: a dictionary lookup and
5215    /// a directory binary search. `None` for another file layout, or no such
5216    /// graph.
5217    pub(crate) fn named_graph_ordinal(&self, iri: &str) -> Option<usize> {
5218        match &self.named_graphs {
5219            NamedGraphsSlot::Quad(q) => q.ordinal_of_iri(&self.dict, iri),
5220            _ => None,
5221        }
5222    }
5223
5224    /// The ordinal of the quad-index graph whose subject id is `g` (as
5225    /// [`named_quad_scan`](Self::named_quad_scan) yields it).
5226    pub(crate) fn named_graph_ordinal_of_id(&self, g: u32) -> Option<usize> {
5227        match &self.named_graphs {
5228            NamedGraphsSlot::Quad(q) => q.ordinal_of_id(g),
5229            _ => None,
5230        }
5231    }
5232
5233    /// A streaming scan of the quads matching `pat` (dictionary ids: subject,
5234    /// predicate, object) over every named graph of a quad-index file, as
5235    /// `(graph subject id, (s, p, o))`; see [`crate::quad::QuadScan`]. `None`
5236    /// for another layout, or when the section cannot be read.
5237    pub(crate) fn named_quad_scan(
5238        &self,
5239        pat: (Option<u32>, Option<u32>, Option<u32>),
5240    ) -> Option<crate::quad::QuadScan> {
5241        match &self.named_graphs {
5242            NamedGraphsSlot::Quad(q) => q.scan(pat),
5243            _ => None,
5244        }
5245    }
5246
5247    /// [`named_graph_at`](Self::named_graph_at) under a declared demand: see
5248    /// [`named_graph_name_owned_demand`](Self::named_graph_name_owned_demand).
5249    pub(crate) fn named_graph_at_demand(
5250        &self,
5251        i: usize,
5252        exhaustive: bool,
5253    ) -> Option<(&str, &GraphIndex)> {
5254        self.named_graphs.graph_at(&self.dict, i, exhaustive)
5255    }
5256
5257    /// IRIs of the named graphs in this dataset (the default graph is unnamed).
5258    /// On a lazy ranged open this walks the whole directory (headers only) —
5259    /// prefer [`named_graph_count`](Self::named_graph_count) when the number
5260    /// is all that's needed.
5261    pub fn graph_names(&self) -> Vec<&str> {
5262        (0..self.named_graphs.count())
5263            .filter_map(|i| self.named_graphs.name_at(&self.dict, i, false))
5264            .collect()
5265    }
5266
5267    /// The permutation index of a named graph, or `None` if absent.
5268    pub fn graph_index(&self, iri: &str) -> Option<&GraphIndex> {
5269        self.named_graphs.find(&self.dict, iri)
5270    }
5271
5272    /// Match a triple pattern in dictionary-ID space (subject/predicate/object
5273    /// IDs), returning integer triples — the fast path used by the BGP engine.
5274    pub fn match_ids(
5275        &self,
5276        pattern: (Option<u32>, Option<u32>, Option<u32>),
5277    ) -> Vec<(u32, u32, u32)> {
5278        self.index.match_pattern(pattern)
5279    }
5280
5281    /// All `(subject_node, object_node)` pairs for a predicate, as unified node
5282    /// IDs — no term resolution. The fast path for graph traversal.
5283    pub fn predicate_pairs(&self, predicate: &str) -> Vec<(u32, u32)> {
5284        let pid = match self.dict.predicate_id(predicate) {
5285            Some(p) => p,
5286            None => return Vec::new(),
5287        };
5288        self.index
5289            .match_pattern((None, Some(pid), None))
5290            .into_iter()
5291            .map(|(s, _p, o)| (self.dict.subject_node(s), self.dict.object_node(o)))
5292            .collect()
5293    }
5294
5295    /// Open via a [`RangeReader`], fetching only the header and the named
5296    /// section ranges — never a linear scan of the whole resource. A full query
5297    /// open touches at most 4 ranges (header, dictionary, index, pyramid-meta).
5298    pub fn open_ranged<R: RangeReader>(reader: &R) -> Result<Self, FileError> {
5299        let head = reader.read_at(0, (HEADER_LEN as u64).min(reader.len()))?;
5300        if crate::v06::open::is_v06(&head) {
5301            // The eager path reads the whole image — the rest of it, in one
5302            // more request — and a 0x06 one then opens from memory.
5303            let mut all = head;
5304            let at = all.len() as u64;
5305            if reader.len() > at {
5306                all.extend_from_slice(&reader.read_at(at, reader.len() - at)?);
5307            }
5308            return Self::open(&all);
5309        }
5310        let header = Header::from_bytes(&head)?;
5311
5312        let dict_bytes = reader.read_at(header.dictionary_offset, header.dictionary_len)?;
5313        let dict = decode_dictionary_container(&dict_bytes, header.dict_codec)?;
5314
5315        check_named_layout(&header)?;
5316        let index_bytes = reader.read_at(header.root_dir_offset, header.root_dir_len)?;
5317        let (index, _) = decode_root_index_container(&index_bytes, &header)?;
5318        let index_section_ranges = decode_index_section_ranges(
5319            &index_bytes,
5320            header.root_dir_offset,
5321            header.perms,
5322            header.index_container_sections(),
5323        )?;
5324
5325        let pyramid = PyramidSlot::Resident(if header.pyramid_meta_len > 0 {
5326            let mb = reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?;
5327            Some(
5328                PyramidMeta::decode(&mb)
5329                    .map_err(|_| FileError::Container("malformed pyramid meta"))?,
5330            )
5331        } else {
5332            None
5333        });
5334
5335        // Fetch the whole TEXT_INDEX section resident (this opener does one range
5336        // read per section; the lazy opener below is the one that defers it).
5337        let (ti_off, ti_len) = header.text_index_payload();
5338        let text_index_bytes = if ti_len > 0 {
5339            Some(reader.read_at(ti_off, ti_len)?)
5340        } else {
5341            None
5342        };
5343        let text_index = resident_text_index_slot(text_index_bytes.as_deref(), header.block_codec)?;
5344        // LABEL_INDEX and SCHEMA: one range read each, held resident like the
5345        // other sections of this opener.
5346        let labels = SectionSlot::Resident(header.label_index_section().and_then(|s| {
5347            let b = reader.read_at(s.offset, s.length).ok()?;
5348            crate::label_index::LabelIndex::from_section(&b, header.block_codec).ok()
5349        }));
5350        let schema = SectionSlot::Resident(read_schema_with_header(reader, &header));
5351
5352        let named_graphs = NamedGraphsSlot::resident(
5353            &header,
5354            |off, len| Ok(reader.read_at(off, len)?),
5355            || {
5356                let nb = reader.read_at(header.named_graphs_offset, header.named_graphs_len)?;
5357                decode_named_graphs(&nb, header.block_codec, header.perms)
5358            },
5359        )?;
5360
5361        // The metadata section (Dataset Card) is deliberately NOT fetched here:
5362        // a ranged query open keeps to its small range budget. Use `Rete::open`
5363        // (or a dedicated card fetch) when the card is actually needed.
5364        let tile_ranges =
5365            tile_file_ranges(&index_bytes, header.root_dir_offset, &index_section_ranges);
5366        Ok(Self {
5367            header,
5368            dict,
5369            index,
5370            index_section_ranges,
5371            tile_ranges,
5372            pyramid,
5373            text_index,
5374            labels,
5375            schema,
5376            named_graphs,
5377            metadata: Vec::new(),
5378            service_client: None,
5379            service_error: std::sync::Mutex::new(None),
5380            spill_threshold: std::sync::atomic::AtomicU64::new(u64::MAX),
5381            v06: None,
5382        })
5383    }
5384
5385    /// Open a `0x06` file whose first bytes are `first`: the header and root
5386    /// come from them; the dictionary and index are two-level directories whose
5387    /// leaves fault on demand; text, labels, schema, pyramid and named graphs
5388    /// are deferred. `budget` applies the default memory budget (the lazy
5389    /// remote path) as [`open_ranged_lazy`](Self::open_ranged_lazy) does.
5390    fn open_v06(
5391        reader: std::sync::Arc<dyn RangeReader + Send + Sync>,
5392        first: &[u8],
5393        budget: bool,
5394    ) -> Result<Self, FileError> {
5395        let f = std::sync::Arc::new(crate::v06::open::V06File::open(reader.clone(), first)?);
5396        let header = crate::v06::logical_header(&f.h);
5397        let rc = reader.concurrency();
5398        let dict = f.dictionary([true; 4])?;
5399        let index = f.index(rc)?;
5400        let (pyramid, text_index, labels, schema) = v06_slots(&f);
5401        let named_graphs = match f.quad(rc) {
5402            Some(q) => NamedGraphsSlot::Quad(q?),
5403            None => NamedGraphsSlot::Resident(Vec::new()),
5404        };
5405        let rete = Self {
5406            header,
5407            dict,
5408            index,
5409            index_section_ranges: [ByteRange::default(); NUM_PERMS],
5410            tile_ranges: Default::default(),
5411            pyramid,
5412            text_index,
5413            labels,
5414            schema,
5415            named_graphs,
5416            metadata: Vec::new(),
5417            service_client: None,
5418            service_error: std::sync::Mutex::new(None),
5419            spill_threshold: std::sync::atomic::AtomicU64::new(u64::MAX),
5420            v06: Some(f),
5421        };
5422        #[cfg(not(target_arch = "wasm32"))]
5423        if budget {
5424            rete.set_memory_budget(match crate::sysmem::default_query_budget_mb() {
5425                0 => None,
5426                mb => Some(mb << 20),
5427            });
5428        }
5429        #[cfg(target_arch = "wasm32")]
5430        let _ = budget;
5431        Ok(rete)
5432    }
5433
5434    /// The open `0x06` file (header, root, reader), `None` for a `0x05` open.
5435    pub fn v06(&self) -> Option<&crate::v06::open::V06File> {
5436        self.v06.as_deref()
5437    }
5438
5439    /// Open via an **owned** [`RangeReader`] with lazy tile faulting (tiled
5440    /// v0.2 files): fetches the header, dictionary, pyramid meta, named graphs,
5441    /// and each permutation's tile **directory** — but no default-graph tile
5442    /// payloads. Tiles fault in (one range request each) the first time a scan
5443    /// touches them, so a selective SPARQL query fetches O(touched tiles)
5444    /// bytes instead of the whole index.
5445    ///
5446    /// **Failure contract:** scans are infallible by design, so a failed tile
5447    /// fetch yields an empty tile and sets a sticky flag — after evaluating,
5448    /// callers MUST check [`index_incomplete`](Self::index_incomplete) and
5449    /// surface an error instead of the (possibly partial) results.
5450    pub fn open_ranged_lazy<R: RangeReader + Send + Sync + 'static>(
5451        reader: R,
5452    ) -> Result<Self, FileError> {
5453        Self::open_ranged_lazy_with(reader, crate::v06::FIRST_READ)
5454    }
5455
5456    /// [`open_ranged_lazy`](Self::open_ranged_lazy) with an explicit first-read
5457    /// size (at least the 1 KiB a `0x05` header needs). The default is
5458    /// [`crate::v06::FIRST_READ`]; a smaller one keeps a byte-accounting test
5459    /// of a tiny `0x05` file from reading the whole file up front.
5460    pub fn open_ranged_lazy_with<R: RangeReader + Send + Sync + 'static>(
5461        reader: R,
5462        first_read: u64,
5463    ) -> Result<Self, FileError> {
5464        // One first read — 64 KiB, or the whole file when smaller — decides the
5465        // format, and every later read inside it is served from memory.
5466        let first_len = first_read.max(HEADER_LEN as u64).min(reader.len());
5467        let first: std::sync::Arc<[u8]> = std::sync::Arc::from(reader.read_at(0, first_len)?);
5468        #[cfg(feature = "v06-parity")]
5469        if !crate::v06::open::is_v06(&first) {
5470            let all = reader.read_at(0, reader.len())?;
5471            if let Some(v6) = parity_image(&all) {
5472                let r: std::sync::Arc<dyn RangeReader + Send + Sync> =
5473                    std::sync::Arc::new(crate::reader::BytesReader(std::sync::Arc::from(v6)));
5474                let head = r.read_at(0, crate::v06::FIRST_READ.min(r.len()))?;
5475                return Self::open_v06(r, &head, true);
5476            }
5477        }
5478        let reader = crate::reader::PrefixReader::new(reader, first.clone());
5479        if crate::v06::open::is_v06(&first) {
5480            let r: std::sync::Arc<dyn RangeReader + Send + Sync> = std::sync::Arc::new(reader);
5481            return Self::open_v06(r, &first, true);
5482        }
5483        let head = reader.read_at(0, HEADER_LEN as u64)?;
5484        let header = Header::from_bytes(&head)?;
5485        check_named_layout(&header)?;
5486        let reader = std::sync::Arc::new(reader);
5487        // Captured before the loader closures take the Arc: the reader's
5488        // concurrent-range fan-out, stamped onto the index for the planner.
5489        let read_concurrency = reader.concurrency();
5490
5491        // Lazily-chunked dictionary: locate the four sections, fetch each
5492        // section's header + restart table + chunk directory (small), and
5493        // fault the chunk bodies in on first term lookup.
5494        let open_dict = || ranged_chunked_dictionary(&reader, &header, [true; 4]);
5495
5496        // Locate the six index section payloads (container framing only) and
5497        // fetch just their tile directories — shared with the per-named-graph
5498        // lazy opener, which opens a large graph's container the same way.
5499        let reader_dyn: std::sync::Arc<dyn RangeReader + Send + Sync> = reader.clone();
5500        let open_index = || {
5501            open_index_container_lazy(
5502                &reader_dyn,
5503                ByteRange {
5504                    offset: header.root_dir_offset,
5505                    len: header.root_dir_len,
5506                },
5507                header.block_codec,
5508                header.has_tile_synopsis(),
5509                read_concurrency,
5510                header.perms,
5511                header.index_container_sections(),
5512            )
5513        };
5514
5515        // Each of the two is a chain of small dependent reads (a container's
5516        // sections are found by walking their length prefixes), ~8 round trips
5517        // apiece on a real file, and the header already says where both
5518        // containers start. So a reader that can keep several reads in flight
5519        // walks them at the same time: the open costs the longer chain, not
5520        // the sum. Same reads, same result; only their overlap changes.
5521        let (dict, index) = open_side_by_side(read_concurrency, open_dict, open_index);
5522        let dict = dict?;
5523        let (index, index_section_ranges, tile_ranges) = index?;
5524
5525        // The pyramid meta is large on real graphs (tens of MB) and SPARQL never
5526        // reads it, so defer its fetch: it faults in only if `pyramid()` is
5527        // called (community / pyramid_tree / inspect queries).
5528        let pyramid = ranged_pyramid_slot(&reader, &header);
5529
5530        // The TEXT_INDEX section is also deferred: a text search faults the token
5531        // table on first call (the leading varint then its compressed bytes), then
5532        // fetches individual posting lists by `(offset, len)` — never the whole
5533        // postings blob. A SPARQL query, which never searches, pays nothing.
5534        let text_index = ranged_text_index_slot(&reader, &header);
5535
5536        // LABEL_INDEX: the first prefix search reads its head and walks down.
5537        // SCHEMA: read once, on first use — a stats question, or the planner
5538        // ordering a join that needs a measured selectivity.
5539        let labels = ranged_label_slot(&reader, &header);
5540        let schema = ranged_schema_slot(&reader, &header);
5541
5542        // Named graphs are the last eagerly-fetched section standing on this
5543        // path — and on a many-graph file they dwarf everything else (67 MB
5544        // fetched and ~32k graph indexes decoded before the first query, on a
5545        // file whose queries then touch a handful of graphs). Defer them like
5546        // the pyramid and text index: nothing is read at open; a query walks
5547        // the directory and decodes only the graphs it touches.
5548        let named_graphs = if let Some(q) = header.quad_index_section() {
5549            // The quad index: nothing read at open either; the preamble on the
5550            // first named-graph touch, then only the routing parts and tiles
5551            // a query uses.
5552            NamedGraphsSlot::Quad(crate::quad::QuadNamed::new(
5553                reader_dyn,
5554                ByteRange {
5555                    offset: q.offset,
5556                    len: q.length,
5557                },
5558                header.block_codec,
5559                header.perms,
5560                read_concurrency,
5561            ))
5562        } else if header.named_graphs_len > 0 {
5563            NamedGraphsSlot::Lazy(LazyNamedGraphs::new(
5564                reader_dyn,
5565                ByteRange {
5566                    offset: header.named_graphs_offset,
5567                    len: header.named_graphs_len,
5568                },
5569                header.block_codec,
5570                header.has_tile_synopsis(),
5571                read_concurrency,
5572                header.perms,
5573            ))
5574        } else {
5575            NamedGraphsSlot::Resident(Vec::new())
5576        };
5577
5578        let rete = Self {
5579            header,
5580            dict,
5581            index,
5582            index_section_ranges,
5583            tile_ranges,
5584            pyramid,
5585            text_index,
5586            labels,
5587            schema,
5588            named_graphs,
5589            metadata: Vec::new(),
5590            service_client: None,
5591            service_error: std::sync::Mutex::new(None),
5592            spill_threshold: std::sync::atomic::AtomicU64::new(u64::MAX),
5593            v06: None,
5594        };
5595        // A lazy open is the path for files too big to read whole, and the
5596        // one a long-lived session (relay, Python/R/Java clients, `rete serve`)
5597        // keeps resident across queries. Unbounded, its dictionary and tile
5598        // caches grow with everything any query ever touched: a GROUP BY over
5599        // a 9.5-billion-triple file ran a 16 GB container out of memory. Cap
5600        // them by default; a caller that wants another figure (or unlimited)
5601        // calls `set_memory_budget` again. The default is sized from the
5602        // memory this process can still use (`RETE_QUERY_BUDGET_MB` overrides
5603        // it; see `crate::sysmem`): a fixed 4096 MiB did not fit a 4 GiB
5604        // container. Not on wasm32, whose 4 GiB address space makes the default
5605        // meaningless and whose callers bound their own range budgets.
5606        #[cfg(not(target_arch = "wasm32"))]
5607        rete.set_memory_budget(match crate::sysmem::default_query_budget_mb() {
5608            0 => None,
5609            mb => Some(mb << 20),
5610        });
5611        Ok(rete)
5612    }
5613
5614    /// Did any lazy fetch (index tile or dictionary chunk) fail since this
5615    /// `Rete` was opened? When true, query results may be silently incomplete —
5616    /// callers using [`Rete::open_ranged_lazy`] must check this after
5617    /// evaluating and turn it into an error.
5618    pub fn index_incomplete(&self) -> bool {
5619        self.index.load_incomplete()
5620            || self.dict.load_incomplete()
5621            || self.named_graphs.load_incomplete()
5622    }
5623
5624    /// Forget recorded lazy-fetch failures — the start-of-evaluation reset for
5625    /// a RESIDENT session (a browser worker holding one `Rete` across many
5626    /// queries): it makes [`index_incomplete`](Self::index_incomplete) a
5627    /// per-query verdict instead of a per-open one, so a single transient
5628    /// network failure no longer fails every subsequent query on the session.
5629    /// Sound because failed tiles/chunks are never cached — the next
5630    /// evaluation simply retries the fetch.
5631    pub fn reset_load_failures(&self) {
5632        self.index.reset_load_failure();
5633        self.dict.reset_load_failure();
5634        self.named_graphs.reset_load_failures();
5635    }
5636
5637    /// Bound peak resident memory to roughly `budget` bytes by capping the
5638    /// evictable dictionary chunk cache and the default-graph index tile cache
5639    /// (bounded-export phase 2). `None` or `u64::MAX` means unlimited — nothing
5640    /// is evicted and residency is identical to a plain open, which is what
5641    /// `--in-memory` and every query/wasm path use.
5642    ///
5643    /// Sound at any cap: the dictionary and the default-graph index both fault
5644    /// their chunks/tiles through a loader, so an evicted body is simply
5645    /// re-faulted on its next touch; the export dump reads each needed body
5646    /// while holding it (the cache `Arc` handout invariant), so eviction never
5647    /// drops bytes in use. Named-graph indexes are **not** capped here — a small
5648    /// resident graph has no loader and could never reload an evicted tile — so
5649    /// they stay unlimited and are released whole per slot instead (see
5650    /// [`release_named_graph`](Self::release_named_graph)).
5651    ///
5652    /// The open's **fixed** heap — chunk and tile directories, the named-graph
5653    /// directory walked so far ([`fixed_overhead`](Self::fixed_overhead)) — is
5654    /// paid whatever the caches hold, so it comes out of the dictionary share:
5655    /// the caps plus the fixed heap stay within `budget`. It grows with the
5656    /// file's chunk/tile/graph count (~18 MB of directories on the 14.75 GiB
5657    /// openaire-relation, 88 MB with its tile directories; 224 MB on a pre-#198
5658    /// epfl-infoscience whose chunk keys are whole boundary terms). Calling
5659    /// this again after a walk of the named-graph directory (as `rete export`
5660    /// does once it has listed the graph slots) re-subtracts the larger figure.
5661    /// The dictionary share never drops below its 64 MiB floor, which is the
5662    /// only way the total can be exceeded: a file whose directories alone
5663    /// approach the budget.
5664    ///
5665    /// Returns the split actually applied (dictionary share net of the fixed
5666    /// heap), for `RETE_OPEN_DEBUG` logging.
5667    pub fn set_memory_budget(&self, budget: Option<u64>) -> MemoryBudget {
5668        let total = budget.unwrap_or(u64::MAX);
5669        self.set_spill_threshold(budget.map(spill_threshold_for));
5670        let mut split = split_memory_budget(total);
5671        let fixed = if total == u64::MAX {
5672            0
5673        } else {
5674            self.fixed_overhead().total()
5675        };
5676        if split.dict_cache != u64::MAX {
5677            split.dict_cache = split
5678                .dict_cache
5679                .saturating_sub(fixed)
5680                .max(MIN_DICT_CACHE_CAP);
5681        }
5682        self.dict.set_cache_cap(split.dict_cache);
5683        self.index.set_cache_cap(split.tile_cache);
5684        // Large named graphs (opened lazily, loader-bearing) get the same tile
5685        // cap; small resident ones stay unlimited (bounded by size + per-slot
5686        // release). Combined with `release_named_graph`, a many-graph dump holds
5687        // one graph's tiles at a time, each capped.
5688        match &self.named_graphs {
5689            NamedGraphsSlot::Lazy(lazy) => lazy.set_tile_cap(split.tile_cache),
5690            // Every quad-index view, and the tiles they share, re-fault
5691            // through loaders — on every open path — so they take the cap too.
5692            NamedGraphsSlot::Quad(q) => q.set_tile_cap(split.tile_cache),
5693            NamedGraphsSlot::Resident(_) => {}
5694        }
5695        if std::env::var("RETE_OPEN_DEBUG").is_ok() {
5696            eprintln!(
5697                "[memory-budget] total={} dict_cache={} tile_cache={} block_cache={} fixed={:.1}MiB",
5698                if total == u64::MAX {
5699                    "unlimited".to_string()
5700                } else {
5701                    format!("{}MiB", total >> 20)
5702                },
5703                fmt_cap(split.dict_cache),
5704                fmt_cap(split.tile_cache),
5705                fmt_cap(split.block_cache),
5706                fixed as f64 / (1u64 << 20) as f64,
5707            );
5708        }
5709        split
5710    }
5711
5712    /// How many bytes a query's `COUNT(DISTINCT …)` keeps in memory before it
5713    /// writes sorted runs to the temporary directory (`TMPDIR`) and counts by
5714    /// merging them; the count is exact either way. `u64::MAX` (the default of
5715    /// an eager open) never spills. [`set_memory_budget`](Self::set_memory_budget)
5716    /// sets it to an eighth of the budget, so a lazy open spills past 512 MiB
5717    /// by default.
5718    pub fn spill_threshold(&self) -> u64 {
5719        self.spill_threshold
5720            .load(std::sync::atomic::Ordering::Relaxed)
5721    }
5722
5723    /// Set [`spill_threshold`](Self::spill_threshold) without touching the
5724    /// caches — for an eager (resident) open, whose caches cannot be capped.
5725    /// `None` = never spill.
5726    pub fn set_spill_threshold(&self, bytes: Option<u64>) {
5727        self.spill_threshold.store(
5728            bytes.unwrap_or(u64::MAX),
5729            std::sync::atomic::Ordering::Relaxed,
5730        );
5731    }
5732
5733    /// The dictionary chunk cache's observability counters (decoded chunks and
5734    /// bytes, hits/misses, evictions, resident bytes) — for the bounded-export
5735    /// work-accounting tests and `RETE_OPEN_DEBUG`.
5736    pub fn dict_cache_stats(&self) -> crate::chunk_cache::CacheStats {
5737        self.dict.cache_stats()
5738    }
5739
5740    /// Heap bytes this open holds outside the bounded caches — the dictionary
5741    /// chunk directories, the index tile directories and the named-graph
5742    /// directory — for `RETE_OPEN_DEBUG` / `RETE_MEM_TRACE` accounting.
5743    pub fn fixed_overhead(&self) -> FixedOverhead {
5744        FixedOverhead {
5745            dict_directory: self.dict.directory_bytes(),
5746            index_directory: self.index.directory_bytes(),
5747            named_graphs: self.named_graphs.directory_bytes(),
5748        }
5749    }
5750
5751    /// The default-graph index tile cache's observability counters.
5752    pub fn index_cache_stats(&self) -> crate::chunk_cache::CacheStats {
5753        self.index.cache_stats()
5754    }
5755
5756    /// The current dictionary chunk-cache byte cap (`u64::MAX` = unlimited).
5757    pub fn dict_cache_cap(&self) -> u64 {
5758        self.dict.cache_cap()
5759    }
5760
5761    /// Release a named graph's decoded index after its slot has been exported,
5762    /// dropping its `GraphIndex` (and, with it, that graph's decoded tiles and
5763    /// its own tile cache) so a many-graph dump does not accumulate one resident
5764    /// index per graph — the next linear floor on many-graph files once the
5765    /// dictionary is bounded (plan §6). A no-op for the default graph, an
5766    /// unknown IRI, a resident (in-memory/eager) open, or a graph never opened.
5767    ///
5768    /// Takes `&mut self`: the export loop finishes one slot's dump (which only
5769    /// borrows `&self`) before releasing, so no outstanding borrow of the index
5770    /// exists. Correct because a released index is simply re-opened (headers +
5771    /// tile directory) on a later access — the same lazy contract as a chunk
5772    /// that was never faulted.
5773    pub fn release_named_graph(&mut self, iri: &str) {
5774        match &mut self.named_graphs {
5775            NamedGraphsSlot::Lazy(lazy) => lazy.release(iri),
5776            NamedGraphsSlot::Quad(q) => q.release(&self.dict, iri),
5777            NamedGraphsSlot::Resident(_) => {}
5778        }
5779    }
5780
5781    fn resolve_query_pattern(
5782        &self,
5783        s: Option<&str>,
5784        p: Option<&str>,
5785        o: Option<&str>,
5786    ) -> Option<Pattern> {
5787        let sid = match s {
5788            Some(t) => match self.dict.subject_id(t) {
5789                Some(id) => Some(id),
5790                None => return None,
5791            },
5792            None => None,
5793        };
5794        let pid = match p {
5795            Some(t) => match self.dict.predicate_id(t) {
5796                Some(id) => Some(id),
5797                None => return None,
5798            },
5799            None => None,
5800        };
5801        let oid = match o {
5802            Some(t) => match self.dict.object_id(t) {
5803                Some(id) => Some(id),
5804                None => return None,
5805            },
5806            None => None,
5807        };
5808        Some((sid, pid, oid))
5809    }
5810
5811    /// Evaluate a triple pattern and include the file/index provenance for every
5812    /// matched result. A bound term that is unknown to the dictionary yields no
5813    /// matches.
5814    pub fn query_with_provenance(
5815        &self,
5816        s: Option<&str>,
5817        p: Option<&str>,
5818        o: Option<&str>,
5819    ) -> Vec<TripleProvenance> {
5820        let pattern = match self.resolve_query_pattern(s, p, o) {
5821            Some(pattern) => pattern,
5822            None => return Vec::new(),
5823        };
5824
5825        let index_permutation = GraphIndex::best_permutation_in(self.header.perms, pattern);
5826        let si = index_permutation.section_index();
5827        let (dictionary_range, index_range, index_section_range, pyramid_range) = match &self.v06 {
5828            Some(f) => {
5829                use crate::v06::kind;
5830                let sec = |k| f.section(k).unwrap_or_default();
5831                let perm = f
5832                    .root
5833                    .unit(crate::v06::tag::INDEX)
5834                    .and_then(|u| crate::v06::decode_index_root(u).ok())
5835                    .and_then(|v| v.into_iter().find(|p| p.si == si))
5836                    .map(|p| ByteRange {
5837                        offset: sec(kind::INDEX_DATA).offset + p.data_off,
5838                        len: p.data_len,
5839                    })
5840                    .unwrap_or_default();
5841                (
5842                    sec(kind::DICT_DATA),
5843                    sec(kind::INDEX_DATA),
5844                    perm,
5845                    f.section(kind::PYRAMID),
5846                )
5847            }
5848            None => (
5849                ByteRange {
5850                    offset: self.header.dictionary_offset,
5851                    len: self.header.dictionary_len,
5852                },
5853                ByteRange {
5854                    offset: self.header.root_dir_offset,
5855                    len: self.header.root_dir_len,
5856                },
5857                self.index_section_ranges[si],
5858                (self.header.pyramid_meta_len > 0).then_some(ByteRange {
5859                    offset: self.header.pyramid_meta_offset,
5860                    len: self.header.pyramid_meta_len,
5861                }),
5862            ),
5863        };
5864
5865        let tiles = &self.tile_ranges[si];
5866        let v06_tiles = self.v06.is_some().then(|| self.index.tile_sections()[si]);
5867        self.index
5868            .match_pattern(pattern)
5869            .into_iter()
5870            .filter_map(|(s, p, o)| {
5871                let terms = (
5872                    self.dict.subject_term(s)?,
5873                    self.dict.predicate_term(p)?,
5874                    self.dict.object_term(o)?,
5875                );
5876                // The physical tile holding this match: the one whose
5877                // leading-id range covers the match's permuted leading id.
5878                let a = index_permutation.forward((s, p, o)).0;
5879                let (tile, tile_range) = if let Some(tl) = v06_tiles {
5880                    let ti = tl.first_max_ge(a);
5881                    match (ti < tl.len() && tl[ti].leading_range().0 <= a)
5882                        .then(|| tl.range(ti))
5883                        .flatten()
5884                    {
5885                        Some((offset, len)) => (
5886                            Some(format!("{}/{ti}", index_permutation.name())),
5887                            Some(ByteRange { offset, len }),
5888                        ),
5889                        None => (None, None),
5890                    }
5891                } else {
5892                    let ti = tiles.partition_point(|&(_, max_a, _)| max_a < a);
5893                    match tiles.get(ti) {
5894                        Some(&(min_a, _, range)) if min_a <= a => (
5895                            Some(format!("{}/{ti}", index_permutation.name())),
5896                            Some(range),
5897                        ),
5898                        _ => (None, None),
5899                    }
5900                };
5901                Some(TripleProvenance {
5902                    terms,
5903                    ids: (s, p, o),
5904                    graph: None,
5905                    matched_pattern: pattern,
5906                    index_permutation,
5907                    dictionary_range,
5908                    index_range,
5909                    index_section_range,
5910                    pyramid_range,
5911                    tile,
5912                    tile_range,
5913                })
5914            })
5915            .collect()
5916    }
5917
5918    /// Evaluate a triple pattern given as optional term strings, returning
5919    /// matching triples resolved back to terms. A bound term that is unknown to
5920    /// the dictionary yields no matches.
5921    pub fn query(&self, s: Option<&str>, p: Option<&str>, o: Option<&str>) -> Vec<TermTriple> {
5922        self.query_with_provenance(s, p, o)
5923            .into_iter()
5924            .map(|m| m.terms)
5925            .collect()
5926    }
5927
5928    /// Match a triple pattern **within a single graph** — `None` is the default
5929    /// graph, `Some(iri)` a named graph — resolving matches to canonical terms.
5930    /// This is [`Rete::query`] (default-graph only) generalized to any graph: the
5931    /// graph-scoped primitive a quad-aware consumer (e.g. an RDF4J `Sail`'s
5932    /// `getStatements`) needs. An unknown graph IRI, or a bound term absent from
5933    /// the shared dictionary, yields an empty result. All graphs share one
5934    /// dictionary, so the pattern resolves once against that ID space.
5935    pub fn query_in_graph(
5936        &self,
5937        graph: Option<&str>,
5938        s: Option<&str>,
5939        p: Option<&str>,
5940        o: Option<&str>,
5941    ) -> Vec<TermTriple> {
5942        let pattern = match self.resolve_query_pattern(s, p, o) {
5943            Some(pattern) => pattern,
5944            None => return Vec::new(),
5945        };
5946        let index = match graph {
5947            None => &self.index,
5948            Some(g) => match self.graph_index(g) {
5949                Some(i) => i,
5950                None => return Vec::new(),
5951            },
5952        };
5953        self.resolve_matches(index.match_pattern(pattern))
5954    }
5955
5956    /// Resolve matched id-triples to terms, with one coalesced dictionary
5957    /// fault for exactly their terms.
5958    fn resolve_matches(&self, ids: Vec<Triple>) -> Vec<TermTriple> {
5959        // One coalesced dictionary fault for THIS pattern's output, not the
5960        // whole dictionary. `prefetch_all` used to sit here, which made a
5961        // bounded `(s, p, o, g)` lookup — the primitive an RDF4J `Sail`'s
5962        // `getStatements` calls, once per named graph via `query_quads` — pay
5963        // for every term in the file: on `cordis.rete` (801 MB, a 417 MB
5964        // dictionary) a 15-row answer read 416 MB and peaked at 2.5 GB RSS.
5965        // Same call `dump_batch` makes, for the same reason.
5966        if !ids.is_empty() {
5967            let mut nodes = Vec::with_capacity(ids.len() * 2);
5968            let mut preds = Vec::with_capacity(ids.len());
5969            for &(s, p, o) in &ids {
5970                nodes.push(self.dict.subject_node(s));
5971                nodes.push(self.dict.object_node(o));
5972                preds.push(p);
5973            }
5974            self.dict.prefetch_terms(&nodes, &preds);
5975        }
5976        ids.into_iter()
5977            .filter_map(|(s, p, o)| {
5978                Some((
5979                    self.dict.subject_term(s)?,
5980                    self.dict.predicate_term(p)?,
5981                    self.dict.object_term(o)?,
5982                ))
5983            })
5984            .collect()
5985    }
5986
5987    /// Match a triple pattern across the default graph **and every named graph**,
5988    /// tagging each match with its graph (`None` = default). The quad-level
5989    /// companion to [`Rete::query`]; default-graph matches come first, then each
5990    /// named graph in stored order.
5991    pub fn query_quads(
5992        &self,
5993        s: Option<&str>,
5994        p: Option<&str>,
5995        o: Option<&str>,
5996    ) -> Vec<(TermTriple, Option<String>)> {
5997        let mut out: Vec<(TermTriple, Option<String>)> = self
5998            .query_in_graph(None, s, p, o)
5999            .into_iter()
6000            .map(|t| (t, None))
6001            .collect();
6002        let Some(pattern) = self.resolve_query_pattern(s, p, o) else {
6003            return out;
6004        };
6005        for i in 0..self.named_graphs.count() {
6006            // A quad dump visits every graph by construction — exhaustive
6007            // demand, so a lazy walk may read the section in bulk chunks.
6008            let Some(iri) = self.named_graphs.name_at(&self.dict, i, true) else {
6009                continue;
6010            };
6011            // A small quad-index graph through an owned view, dropped here:
6012            // the walk holds one view at a time, not one per graph.
6013            let owned = self.named_graph_transient(i);
6014            let index = match &owned {
6015                Some(view) => view,
6016                None => match self.named_graphs.graph_at(&self.dict, i, true) {
6017                    Some((_, g)) => g,
6018                    None => continue,
6019                },
6020            };
6021            for triple in self.resolve_matches(index.match_pattern(pattern)) {
6022                out.push((triple, Some(iri.to_string())));
6023            }
6024        }
6025        out
6026    }
6027
6028    /// Evaluate one triple pattern through a [`RangeReader`] by fetching only
6029    /// the header, the dictionary, and — for a tiled (v0.2) file — the
6030    /// selected permutation section's tile **directory** plus the tile(s) the
6031    /// bound leading id routes to; an unbound leading id fetches the section's
6032    /// tile body in one request. v0.1 files fetch the whole selected section.
6033    /// Unknown bound terms return an empty result before touching the index.
6034    pub fn query_ranged<R: RangeReader>(
6035        reader: &R,
6036        s: Option<&str>,
6037        p: Option<&str>,
6038        o: Option<&str>,
6039    ) -> Result<Vec<TermTriple>, FileError> {
6040        let head = reader.read_at(0, (HEADER_LEN as u64).min(reader.len()))?;
6041        if crate::v06::open::is_v06(&head) {
6042            return Ok(v06_query_ranged(reader, head, s, p, o, true)?.unwrap_or_default());
6043        }
6044        let routed = match route_pattern(reader, head, s, p, o)? {
6045            Some(routed) => routed,
6046            None => return Ok(Vec::new()),
6047        };
6048        let matches = fetch_routed_matches(reader, &routed)?;
6049        Ok(matches
6050            .into_iter()
6051            .filter_map(|(s, p, o)| {
6052                Some((
6053                    routed.dict.subject_term(s)?,
6054                    routed.dict.predicate_term(p)?,
6055                    routed.dict.object_term(o)?,
6056                ))
6057            })
6058            .collect())
6059    }
6060
6061    /// Route one triple pattern to its permutation section without fetching
6062    /// any payload bytes. Returns `false` when a bound term is unknown and the
6063    /// index was skipped.
6064    pub fn route_pattern_ranged<R: RangeReader>(
6065        reader: &R,
6066        s: Option<&str>,
6067        p: Option<&str>,
6068        o: Option<&str>,
6069    ) -> Result<bool, FileError> {
6070        let head = reader.read_at(0, (HEADER_LEN as u64).min(reader.len()))?;
6071        if crate::v06::open::is_v06(&head) {
6072            return Ok(v06_query_ranged(reader, head, s, p, o, false)?.is_some());
6073        }
6074        Ok(route_pattern(reader, head, s, p, o)?.is_some())
6075    }
6076}
6077
6078/// [`Rete::query_ranged`] / [`Rete::route_pattern_ranged`] on a `0x06` file
6079/// through a borrowed reader: the front and the dictionary data — contiguous,
6080/// what the `0x05` path reads as header + dictionary — in one more request
6081/// after `head`, the pattern resolved there, then (with `fetch`) the routed
6082/// tiles in one span. `None` when a bound term is not in the dictionary.
6083fn v06_query_ranged<R: RangeReader>(
6084    reader: &R,
6085    head: Vec<u8>,
6086    s: Option<&str>,
6087    p: Option<&str>,
6088    o: Option<&str>,
6089    fetch: bool,
6090) -> Result<Option<Vec<TermTriple>>, FileError> {
6091    use crate::v06::{kind, open::SparseReader, Header06, Root06, HEADER_LEN};
6092    let len = reader.len();
6093    let h = Header06::from_bytes(&head)?;
6094    let root_end = (HEADER_LEN as u64 + h.root_len as u64).min(len);
6095    let root_bytes = if root_end <= head.len() as u64 {
6096        head[HEADER_LEN..root_end as usize].to_vec()
6097    } else {
6098        reader.read_at(HEADER_LEN as u64, root_end - HEADER_LEN as u64)?
6099    };
6100    let root = Root06::decode(&root_bytes, h.internal_codec)?;
6101    let dict = root.section(kind::DICT_DATA).and_then(|d| d.abs(&h).ok());
6102    // The open below reads only the 1 KiB it must (not the 64 KiB first
6103    // read): what it needs past that is the front and the dictionary, here.
6104    let end = dict
6105        .map_or(h.data_offset, |d| d.offset + d.len)
6106        .max(crate::header::HEADER_LEN as u64)
6107        .min(len);
6108    let mut held = head;
6109    if end > held.len() as u64 {
6110        let at = held.len() as u64;
6111        held.extend_from_slice(&reader.read_at(at, end - at)?);
6112    }
6113    let sparse = SparseReader::fetch(reader, held, &[])?;
6114    let rete = Rete::open_ranged_lazy_with(sparse, 0)?;
6115    let Some(pattern) = resolve_query_pattern(rete.dictionary(), s, p, o) else {
6116        return Ok(None);
6117    };
6118    if !fetch {
6119        return Ok(Some(Vec::new()));
6120    }
6121    let perm = GraphIndex::best_permutation_in(h.perms(), pattern);
6122    let si = perm.section_index();
6123    let index = rete.default_index();
6124    let list = index.tile_sections()[si];
6125    let n = list.len();
6126    let [pa, _, _] = perm.order_pattern(pattern);
6127    let (start, stop) = match pa {
6128        Some(a) => {
6129            let start = list.first_max_ge(a);
6130            let mut stop = start;
6131            while stop < n {
6132                list.ensure_span(stop, stop + 1);
6133                if list[stop].leading_range().0 > a {
6134                    break;
6135                }
6136                stop += 1;
6137            }
6138            (start, stop)
6139        }
6140        None => {
6141            list.ensure_span(0, n);
6142            (0, n)
6143        }
6144    };
6145    let ranges: Vec<(u64, u64)> = (start..stop)
6146        .filter_map(|i| index.tile_range(si, i))
6147        .collect();
6148    let mut out = Vec::new();
6149    if let (Some(&(lo, _)), Some(&(last, last_len))) = (ranges.first(), ranges.last()) {
6150        // The routed tiles are one contiguous run of the section: one request.
6151        let body = reader.read_at(lo, last + last_len - lo)?;
6152        for &(off, l) in &ranges {
6153            let at = (off - lo) as usize;
6154            let tile = decompress(h.block_codec, &body[at..at + l as usize])?;
6155            out.extend(GraphIndex::match_serialized_block(&tile, perm, pattern));
6156        }
6157    }
6158    out.sort_unstable();
6159    let d = rete.dictionary();
6160    Ok(Some(
6161        out.into_iter()
6162            .filter_map(|(s, p, o)| {
6163                Some((d.subject_term(s)?, d.predicate_term(p)?, d.object_term(o)?))
6164            })
6165            .collect(),
6166    ))
6167}
6168
6169/// A pattern routed to its permutation section: everything needed to fetch
6170/// matches, with no payload bytes read yet.
6171struct RoutedPattern {
6172    dict: Dictionary,
6173    pattern: Pattern,
6174    permutation: IndexPermutation,
6175    header: Header,
6176    /// Absolute byte range of the selected section's payload.
6177    section: ByteRange,
6178}
6179
6180/// Resolve a pattern against the remote dictionary and locate its permutation
6181/// section (header + dictionary + container framing only).
6182fn route_pattern<R: RangeReader>(
6183    reader: &R,
6184    head: Vec<u8>,
6185    s: Option<&str>,
6186    p: Option<&str>,
6187    o: Option<&str>,
6188) -> Result<Option<RoutedPattern>, FileError> {
6189    let header = Header::from_bytes(&head)?;
6190
6191    let dict_bytes = reader.read_at(header.dictionary_offset, header.dictionary_len)?;
6192    let dict = decode_dictionary_container(&dict_bytes, header.dict_codec)?;
6193
6194    let Some(pattern) = resolve_query_pattern(&dict, s, p, o) else {
6195        return Ok(None);
6196    };
6197    let permutation = GraphIndex::best_permutation_in(header.perms, pattern);
6198    let section = locate_container_section_ranged(
6199        reader,
6200        header.root_dir_offset,
6201        header.root_dir_len,
6202        header
6203            .perms
6204            .position(permutation)
6205            .ok_or(FileError::Container("routed to an absent permutation"))?,
6206        header.index_container_sections() as u64,
6207    )?;
6208    Ok(Some(RoutedPattern {
6209        dict,
6210        pattern,
6211        permutation,
6212        header,
6213        section,
6214    }))
6215}
6216
6217/// Fetch and scan a routed pattern's matches: read the tile directory, then only
6218/// the matching tile byte ranges (the run of covering tiles for a bound leading
6219/// id — one for ordinary groups, several for a split mega-group — the
6220/// O(matching bytes) promise).
6221fn fetch_routed_matches<R: RangeReader>(
6222    reader: &R,
6223    routed: &RoutedPattern,
6224) -> Result<Vec<Triple>, FileError> {
6225    let dir = read_tile_directory_ranged(reader, routed.section)?;
6226    let [pa, _, _] = routed.permutation.order_pattern(routed.pattern);
6227    let codec = routed.header.block_codec;
6228    let mut out = Vec::new();
6229    match pa {
6230        // Bound leading id: the run of covering tiles (several for a split
6231        // mega-group; one otherwise).
6232        Some(a) => {
6233            for e in dir.iter().filter(|e| e.min_a <= a && a <= e.max_a) {
6234                let len = e.end - e.start;
6235                let bytes = reader.read_at(checked_end(routed.section.offset, e.start)?, len)?;
6236                if bytes.len() as u64 != len {
6237                    return Err(FileError::Container("short tile read"));
6238                }
6239                let tile = decompress(codec, &bytes)?;
6240                out.extend(GraphIndex::match_serialized_block(
6241                    &tile,
6242                    routed.permutation,
6243                    routed.pattern,
6244                ));
6245            }
6246        }
6247        // Unbound leading id: every tile matters — fetch the contiguous tile
6248        // body in one request and slice it.
6249        None => {
6250            if let (Some(first), Some(last)) = (dir.first(), dir.last()) {
6251                let base = first.start;
6252                let body =
6253                    reader.read_at(checked_end(routed.section.offset, base)?, last.end - base)?;
6254                for e in &dir {
6255                    // Checked: a reader may return fewer bytes than asked.
6256                    let stored = checked_slice(&body, e.start - base, e.end - e.start)
6257                        .ok_or(FileError::Container("short tile read"))?;
6258                    let tile = decompress(codec, stored)?;
6259                    out.extend(GraphIndex::match_serialized_block(
6260                        &tile,
6261                        routed.permutation,
6262                        routed.pattern,
6263                    ));
6264                }
6265            }
6266        }
6267    }
6268    out.sort_unstable();
6269    Ok(out)
6270}
6271
6272fn resolve_query_pattern(
6273    dict: &Dictionary,
6274    s: Option<&str>,
6275    p: Option<&str>,
6276    o: Option<&str>,
6277) -> Option<Pattern> {
6278    let sid = match s {
6279        Some(t) => Some(dict.subject_id(t)?),
6280        None => None,
6281    };
6282    let pid = match p {
6283        Some(t) => Some(dict.predicate_id(t)?),
6284        None => None,
6285    };
6286    let oid = match o {
6287        Some(t) => Some(dict.object_id(t)?),
6288        None => None,
6289    };
6290    Some((sid, pid, oid))
6291}
6292
6293/// The lazily-chunked dictionary of a ranged open: per section, fetch the
6294/// header + chunk directory and fault the chunk bodies in on first term lookup.
6295///
6296/// `want` selects which of the four sections (0 shared, 1 subject-only,
6297/// 2 object-only, 3 predicates) are really read; a skipped one becomes an empty
6298/// section whose terms resolve to `None`. On a file written before #198 a
6299/// **directory is not small**: it carried each chunk's first term verbatim, so
6300/// the object-only section of a dataset that stores abstracts can run to
6301/// hundreds of megabytes, and fetching it is most of what a remote open costs.
6302/// Current builds key it by the shortest separator instead (a few bytes per
6303/// chunk), but that is write-side, so every already-published file still pays.
6304/// A reader that only ever resolves subjects (see [`SearchView`]) skips
6305/// sections 2 and 3 and pays none of it. [`Rete::open_ranged_lazy`] wants all
6306/// four.
6307fn ranged_chunked_dictionary<R: RangeReader + Send + Sync + 'static>(
6308    reader: &std::sync::Arc<R>,
6309    header: &Header,
6310    want: [bool; 4],
6311) -> Result<Dictionary, FileError> {
6312    // One (unlimited) cache shared across the four sections, keyed by
6313    // (section_index, chunk_index).
6314    let cache = crate::chunk_cache::ChunkCache::unlimited_arc();
6315    let mut dict_sections: Vec<crate::dict::ChunkedSection> = Vec::with_capacity(4);
6316    let mut section_ranges: [std::sync::Arc<Vec<ByteRange>>; 4] = Default::default();
6317    for si in 0..4 {
6318        if !want[si] {
6319            dict_sections.push(crate::dict::ChunkedSection::from_parts(
6320                crate::dict::SectionMeta {
6321                    term_count: 0,
6322                    restart_interval: 1,
6323                    restart_offsets: Vec::new(),
6324                },
6325                Vec::new(),
6326                None,
6327                cache.clone(),
6328                si as u8,
6329            ));
6330            continue;
6331        }
6332        let section = locate_container_section_ranged(
6333            reader.as_ref(),
6334            header.dictionary_offset,
6335            header.dictionary_len,
6336            si,
6337            4,
6338        )?;
6339        let (meta, entries) = read_dict_dir_ranged(reader.as_ref(), section)?;
6340        // One copy of the chunk ranges, shared by this section's loaders and
6341        // the dictionary's cross-section batch loader.
6342        let ranges: std::sync::Arc<Vec<ByteRange>> = std::sync::Arc::new(
6343            entries
6344                .iter()
6345                .map(|e| ByteRange {
6346                    offset: section.offset + e.start,
6347                    len: (e.end - e.start),
6348                })
6349                .collect(),
6350        );
6351        section_ranges[si] = ranges.clone();
6352        let chunks: Vec<crate::dict::SectionChunk> = entries
6353            .into_iter()
6354            .map(|e| crate::dict::SectionChunk::new(e.first_run, e.key, e.body_start))
6355            .collect();
6356        let chunk_reader = reader.clone();
6357        let codec = header.dict_codec;
6358        let loader_ranges = ranges.clone();
6359        let loader: crate::dict::ChunkLoader = Box::new(move |ci| {
6360            let range = loader_ranges.get(ci)?;
6361            let bytes = chunk_reader.read_at(range.offset, range.len).ok()?;
6362            decompress(codec, &bytes).ok()
6363        });
6364        // Full-section sweeps (export/dump) batch their chunk fetches:
6365        // adjacent chunk ranges coalesce into a handful of range reads.
6366        let bulk_reader = reader.clone();
6367        let bulk: crate::dict::ChunkBulkLoader = Box::new(move |cis| {
6368            let want: Option<Vec<ByteRange>> =
6369                cis.iter().map(|&ci| ranges.get(ci).copied()).collect();
6370            let blobs = read_coalesced(bulk_reader.as_ref(), &want?, DICT_COALESCE_GAP)?;
6371            blobs.iter().map(|b| decompress(codec, b).ok()).collect()
6372        });
6373        dict_sections.push(
6374            crate::dict::ChunkedSection::from_parts(
6375                meta,
6376                chunks,
6377                Some(loader),
6378                cache.clone(),
6379                si as u8,
6380            )
6381            .with_bulk_loader(bulk),
6382        );
6383    }
6384    let dict_arr: [crate::dict::ChunkedSection; 4] = dict_sections
6385        .try_into()
6386        .map_err(|_| FileError::Container("expected 4 dictionary sections"))?;
6387    // Decoding a result touches several sections at once; this fetches all
6388    // their missing chunks in one batch (pairs arrive section by section in
6389    // ascending chunk order, so ascending in the file, as coalescing wants).
6390    let multi_reader = reader.clone();
6391    let codec = header.dict_codec;
6392    let multi: crate::dict::MultiChunkBulkLoader = Box::new(move |pairs| {
6393        let want: Option<Vec<ByteRange>> = pairs
6394            .iter()
6395            .map(|&(si, ci)| section_ranges.get(si as usize)?.get(ci).copied())
6396            .collect();
6397        let blobs = read_coalesced(multi_reader.as_ref(), &want?, DICT_COALESCE_GAP)?;
6398        blobs.iter().map(|b| decompress(codec, b).ok()).collect()
6399    });
6400    Ok(Dictionary::from_chunked_sections(dict_arr).with_multi_bulk_loader(multi))
6401}
6402
6403/// Run two independent steps of an open: at the same time when the reader can
6404/// keep several reads in flight (`concurrency > 1`) on a target with threads,
6405/// else one after the other. Either way both run to completion and their
6406/// results come back as they would inline; a panic in either propagates.
6407#[cfg(not(target_arch = "wasm32"))]
6408fn open_side_by_side<A, B: Send>(
6409    concurrency: usize,
6410    a: impl FnOnce() -> A,
6411    b: impl FnOnce() -> B + Send,
6412) -> (A, B) {
6413    if concurrency <= 1 {
6414        return (a(), b());
6415    }
6416    std::thread::scope(|scope| {
6417        let hb = scope.spawn(b);
6418        let ra = a();
6419        let rb = hb
6420            .join()
6421            .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
6422        (ra, rb)
6423    })
6424}
6425
6426/// wasm32 has no threads here: always one after the other.
6427#[cfg(target_arch = "wasm32")]
6428fn open_side_by_side<A, B>(
6429    _concurrency: usize,
6430    a: impl FnOnce() -> A,
6431    b: impl FnOnce() -> B,
6432) -> (A, B) {
6433    (a(), b())
6434}
6435
6436/// The `v06-parity` test hook: a 0x05 image converted to 0x06 (`None` for a
6437/// 0x06 image, or one the converter refuses, such as per-graph named graphs).
6438#[cfg(feature = "v06-parity")]
6439fn parity_image(bytes: &[u8]) -> Option<Vec<u8>> {
6440    if crate::v06::open::is_v06(bytes) || bytes.len() < HEADER_LEN {
6441        return None;
6442    }
6443    crate::v06::write::convert_image(bytes, crate::v06::write::WriteOpts::default()).ok()
6444}
6445
6446/// [`Rete::file_layout`] for a `0x06` file: the header, the root, the card
6447/// region, then every front and data section, in file order.
6448fn v06_layout(f: &crate::v06::open::V06File) -> Vec<LayoutSegment> {
6449    let h = &f.h;
6450    let seg = |kind: &'static str, label: String, offset: u64, len: u64| LayoutSegment {
6451        kind,
6452        label,
6453        offset,
6454        len,
6455    };
6456    let mut out = vec![
6457        seg(
6458            "header",
6459            format!("header (fixed {} bytes)", crate::v06::HEADER_LEN),
6460            0,
6461            crate::v06::HEADER_LEN as u64,
6462        ),
6463        seg(
6464            "directory",
6465            "root block (section table, directory roots)".into(),
6466            h.root_offset(),
6467            h.root_len as u64,
6468        ),
6469    ];
6470    let c = h.card_range();
6471    if c.len > 0 {
6472        out.push(seg(
6473            "metadata",
6474            "metadata (dataset card, zstd)".into(),
6475            c.offset,
6476            c.len,
6477        ));
6478    }
6479    let b = h.build_range();
6480    if b.len > 0 {
6481        out.push(seg("build-info", "build record".into(), b.offset, b.len));
6482    }
6483    for s in &f.root.sections {
6484        let Ok(a) = s.abs(h) else { continue };
6485        let kind: &'static str = match s.kind {
6486            crate::v06::kind::SCHEMA => "schema",
6487            crate::v06::kind::DICT_DATA => "dictionary",
6488            crate::v06::kind::INDEX_DATA => "tile",
6489            crate::v06::kind::QUAD_DATA => "named-graphs",
6490            crate::v06::kind::LABEL_DATA => "label-index",
6491            crate::v06::kind::TEXT_DATA => "text-index",
6492            crate::v06::kind::PYRAMID => "pyramid",
6493            _ => "directory",
6494        };
6495        out.push(seg(
6496            kind,
6497            crate::v06::kind::name(s.kind).to_string(),
6498            a.offset,
6499            a.len,
6500        ));
6501    }
6502    out.sort_by_key(|s| s.offset);
6503    out
6504}
6505
6506/// The deferred slots of a `0x06` open: pyramid, text index, label index and
6507/// schema, each faulted on first use.
6508#[allow(clippy::type_complexity)]
6509fn v06_slots(
6510    f: &std::sync::Arc<crate::v06::open::V06File>,
6511) -> (
6512    PyramidSlot,
6513    TextIndexSlot,
6514    SectionSlot<crate::label_index::LabelIndex>,
6515    SectionSlot<crate::schema_section::SchemaMeta>,
6516) {
6517    use crate::v06::{P_LABELS, P_PYRAMID, P_SCHEMA, P_TEXT};
6518    let pyramid = if f.h.has(P_PYRAMID) {
6519        let a = f.clone();
6520        PyramidSlot::Lazy {
6521            loader: Box::new(move || PyramidMeta::decode(&a.pyramid_bytes()?).ok()),
6522            cell: std::sync::OnceLock::new(),
6523        }
6524    } else {
6525        PyramidSlot::Resident(None)
6526    };
6527    let text_index = if f.h.has(P_TEXT) {
6528        let (a, b) = (f.clone(), f.clone());
6529        TextIndexSlot::Lazy {
6530            loader: Box::new(move || a.text_index()?.ok()),
6531            cell: std::sync::OnceLock::new(),
6532            token_table: Box::new(move || b.token_table_bytes()),
6533            token_table_cell: std::sync::OnceLock::new(),
6534        }
6535    } else {
6536        TextIndexSlot::Resident {
6537            index: None,
6538            token_table_len: None,
6539        }
6540    };
6541    let labels = if f.h.has(P_LABELS) {
6542        let a = f.clone();
6543        SectionSlot::Lazy {
6544            loader: Box::new(move || a.label_index()?.ok()),
6545            cell: std::sync::OnceLock::new(),
6546        }
6547    } else {
6548        SectionSlot::Resident(None)
6549    };
6550    let schema = if f.h.has(P_SCHEMA) {
6551        let a = f.clone();
6552        SectionSlot::Lazy {
6553            loader: Box::new(move || a.schema()),
6554            cell: std::sync::OnceLock::new(),
6555        }
6556    } else {
6557        SectionSlot::Resident(None)
6558    };
6559    (pyramid, text_index, labels, schema)
6560}
6561
6562/// The pyramid meta is large on real graphs (tens of MB) and SPARQL never reads
6563/// it, so a ranged open defers its fetch: it faults in only if `pyramid()` is
6564/// called (community / pyramid_tree / label-prefix queries).
6565fn ranged_pyramid_slot<R: RangeReader + Send + Sync + 'static>(
6566    reader: &std::sync::Arc<R>,
6567    header: &Header,
6568) -> PyramidSlot {
6569    if header.pyramid_meta_len == 0 {
6570        return PyramidSlot::Resident(None);
6571    }
6572    let pyr_reader = reader.clone();
6573    let pyr_off = header.pyramid_meta_offset;
6574    let pyr_len = header.pyramid_meta_len;
6575    PyramidSlot::Lazy {
6576        loader: Box::new(move || {
6577            let mb = pyr_reader.read_at(pyr_off, pyr_len).ok()?;
6578            PyramidMeta::decode(&mb).ok()
6579        }),
6580        cell: std::sync::OnceLock::new(),
6581    }
6582}
6583
6584/// The deferred TEXT_INDEX of a ranged open: a text search faults the token
6585/// table on first call (the leading varint then its compressed bytes), then
6586/// fetches individual posting lists by `(offset, len)` — never the whole
6587/// postings blob. A caller that never searches pays nothing.
6588fn ranged_text_index_slot<R: RangeReader + Send + Sync + 'static>(
6589    reader: &std::sync::Arc<R>,
6590    header: &Header,
6591) -> TextIndexSlot {
6592    let (ti_off, ti_len) = header.text_index_payload();
6593    if ti_len == 0 {
6594        return TextIndexSlot::Resident {
6595            index: None,
6596            token_table_len: None,
6597        };
6598    }
6599    let ti_reader = reader.clone();
6600    let probe_reader = reader.clone();
6601    let codec = header.block_codec;
6602    TextIndexSlot::Lazy {
6603        loader: Box::new(move || {
6604            // The section opens with `varint token_table_len`; read enough to
6605            // decode it (a uvarint is ≤ 10 bytes), then fetch the varint + the
6606            // compressed token table as one prefix range.
6607            let prefix_len = read_token_table_len(&*ti_reader, ti_off, ti_len)?;
6608            let prefix = ti_reader.read_at(ti_off, prefix_len).ok()?;
6609            let postings_base = crate::text_index::TextIndex::postings_base(&prefix)? as u64;
6610            let postings_abs = ti_off + postings_base;
6611            let pr = ti_reader.clone();
6612            let posting_loader =
6613                Box::new(move |off: u64, len: u64| pr.read_at(postings_abs + off, len).ok());
6614            crate::text_index::TextIndex::from_token_table(&prefix, codec, posting_loader).ok()
6615        }),
6616        cell: std::sync::OnceLock::new(),
6617        // The same leading varint the loader starts from, asked on its own so a
6618        // caller can *state* the cost of a first search without paying it.
6619        token_table: Box::new(move || read_token_table_len(&*probe_reader, ti_off, ti_len)),
6620        token_table_cell: std::sync::OnceLock::new(),
6621    }
6622}
6623
6624/// The `(label, subject term)` pairs of a legacy (pyramid-block) label search.
6625/// The hits' subjects are scattered over the dictionary, so their chunks are
6626/// faulted in one batch first (one round trip on a remote file, as
6627/// [`text_search_in`] does for full-text hits), then decoded in one
6628/// chunk-ordered pass. A subject that does not resolve drops its hit, as
6629/// before; the order is the search's.
6630fn resolve_label_hits(
6631    dict: &Dictionary,
6632    hits: Vec<&crate::meta::LabelEntry>,
6633) -> Vec<(String, String)> {
6634    let ids: Vec<u32> = hits.iter().map(|e| e.subject).collect();
6635    dict.prefetch_subject_terms(&ids);
6636    hits.iter()
6637        .zip(dict.subject_terms(&ids))
6638        .filter_map(|(e, iri)| Some((e.label.clone(), iri?)))
6639        .collect()
6640}
6641
6642/// Full-text search over `ti`, resolving the matched subject ids through `dict`.
6643/// The engine behind [`Rete::text_search`] and [`SearchView::text_search`].
6644fn text_search_in(
6645    ti: Option<&crate::text_index::TextIndex>,
6646    dict: &Dictionary,
6647    words: &[&str],
6648    prefix: Option<&str>,
6649    limit: usize,
6650) -> Vec<String> {
6651    let Some(ti) = ti else {
6652        return Vec::new();
6653    };
6654    // Each query word is tokenized exactly as at build time (so "Glucose"
6655    // matches the stored "glucose"); a word that splits into several tokens
6656    // requires all of them. AND across every required token + the prefix.
6657    let mut acc: Option<Vec<u32>> = None;
6658    if let Some(p) = prefix {
6659        acc = Some(ti.prefix(&p.to_lowercase()));
6660    }
6661    for w in words {
6662        for tok in crate::text_index::tokenize(w) {
6663            let posting = ti.lookup(&tok);
6664            acc = Some(match acc {
6665                Some(a) => intersect_sorted(&a, &posting),
6666                None => posting,
6667            });
6668            if acc.as_ref().is_some_and(|a| a.is_empty()) {
6669                return Vec::new();
6670            }
6671        }
6672    }
6673    let ids = acc.unwrap_or_default();
6674    let mut out = Vec::with_capacity(if limit > 0 {
6675        limit.min(ids.len())
6676    } else {
6677        ids.len()
6678    });
6679    // The hits' subject IRIs are scattered over many dictionary chunks: fault
6680    // the ones a page can return in one batch (on a remote file, one round
6681    // trip instead of one per chunk). Only warms the cache.
6682    let page = if limit > 0 {
6683        limit.min(ids.len())
6684    } else {
6685        ids.len()
6686    };
6687    dict.prefetch_subject_terms(&ids[..page]);
6688    for id in ids {
6689        if let Some(iri) = dict.subject_term(id) {
6690            out.push(iri);
6691            if limit > 0 && out.len() >= limit {
6692                break;
6693            }
6694        }
6695    }
6696    out
6697}
6698
6699/// A `.rete` opened for **search only**: the dictionary's chunk directories, and
6700/// the TEXT_INDEX / pyramid deferred behind their lazy slots. Nothing else.
6701///
6702/// What makes this cheap is skipping dictionary sections 2 and 3 (object-only
6703/// and predicates) — NOT skipping the index. Measured on the published
6704/// `epfl-infoscience.rete` (1.64 GB), a full [`Rete::open_ranged_lazy`] reads
6705/// 536,947,344 B, of which the six permutation tile directories are **49,940 B
6706/// in 40 reads** and the dictionary is 536,896,380 B in 36 reads: the
6707/// object-only chunk directory alone is 234,400,728 B, because that file stores
6708/// every chunk's first term verbatim. Files built since #198 key it by the
6709/// shortest separator (~600 KB on this graph); the published one still does
6710/// not, and only a rebuild changes that. This view opens sections 0 and 1
6711/// only — both search modes return subject IRIs — and costs **21,554 B in 9
6712/// reads** on the same file. It also skips the index container and defers the
6713/// TEXT_INDEX and pyramid, so it pays only for what it reads: the token table
6714/// on the first search, then one range per posting list and per dictionary
6715/// chunk holding a matched subject. This is what backs `rete search-url`.
6716///
6717/// On a file with a LABEL_INDEX section (kind 8) the dictionary is not opened
6718/// at all until a full-text search needs it: the section stores each subject's
6719/// term, so a prefix search costs the header, the section head, one directory
6720/// node per level and one data block.
6721pub struct SearchView {
6722    header: Header,
6723    dict: SectionSlot<Dictionary>,
6724    pyramid: PyramidSlot,
6725    text_index: TextIndexSlot,
6726    labels: SectionSlot<crate::label_index::LabelIndex>,
6727}
6728
6729impl SearchView {
6730    /// Open for search over any [`RangeReader`] (HTTP or local): header +
6731    /// dictionary chunk directories only (none at all on a file with a
6732    /// LABEL_INDEX, until a text search needs them), with the text index,
6733    /// label index and pyramid faulting in on first use.
6734    pub fn open_ranged<R: RangeReader + Send + Sync + 'static>(
6735        reader: R,
6736    ) -> Result<Self, FileError> {
6737        Self::open_ranged_with(reader, crate::v06::FIRST_READ)
6738    }
6739
6740    /// [`open_ranged`](Self::open_ranged) with an explicit first-read size
6741    /// (see [`Rete::open_ranged_lazy_with`]).
6742    pub fn open_ranged_with<R: RangeReader + Send + Sync + 'static>(
6743        reader: R,
6744        first_read: u64,
6745    ) -> Result<Self, FileError> {
6746        let first_len = first_read.max(HEADER_LEN as u64).min(reader.len());
6747        let first: std::sync::Arc<[u8]> = std::sync::Arc::from(reader.read_at(0, first_len)?);
6748        let reader = crate::reader::PrefixReader::new(reader, first.clone());
6749        if crate::v06::open::is_v06(&first) {
6750            let r: std::sync::Arc<dyn RangeReader + Send + Sync> = std::sync::Arc::new(reader);
6751            let f = std::sync::Arc::new(crate::v06::open::V06File::open(r, &first)?);
6752            let header = crate::v06::logical_header(&f.h);
6753            let (pyramid, text_index, labels, _) = v06_slots(&f);
6754            let want = [true, true, false, false];
6755            let dict = if f.h.has(crate::v06::P_LABELS) {
6756                let a = f.clone();
6757                SectionSlot::Lazy {
6758                    loader: Box::new(move || a.dictionary(want).ok()),
6759                    cell: std::sync::OnceLock::new(),
6760                }
6761            } else {
6762                SectionSlot::Resident(Some(f.dictionary(want)?))
6763            };
6764            return Ok(Self {
6765                header,
6766                dict,
6767                pyramid,
6768                text_index,
6769                labels,
6770            });
6771        }
6772        let head = reader.read_at(0, HEADER_LEN as u64)?;
6773        let header = Header::from_bytes(&head)?;
6774        let reader = std::sync::Arc::new(reader);
6775        // Sections 0 (shared) and 1 (subject-only) are the only ones a search
6776        // resolves — both modes return subject IRIs. Skipping 2 and 3 is what
6777        // makes this open cheap where a full one is not.
6778        let dict = if header.label_index_section().is_some() {
6779            let (r, h) = (reader.clone(), header.clone());
6780            SectionSlot::Lazy {
6781                loader: Box::new(move || {
6782                    ranged_chunked_dictionary(&r, &h, [true, true, false, false]).ok()
6783                }),
6784                cell: std::sync::OnceLock::new(),
6785            }
6786        } else {
6787            SectionSlot::Resident(Some(ranged_chunked_dictionary(
6788                &reader,
6789                &header,
6790                [true, true, false, false],
6791            )?))
6792        };
6793        let pyramid = ranged_pyramid_slot(&reader, &header);
6794        let text_index = ranged_text_index_slot(&reader, &header);
6795        let labels = ranged_label_slot(&reader, &header);
6796        Ok(Self {
6797            header,
6798            dict,
6799            pyramid,
6800            text_index,
6801            labels,
6802        })
6803    }
6804
6805    /// Whether the file can answer a prefix search: it carries a LABEL_INDEX
6806    /// section or a pyramid (whose meta may hold the legacy label block). A
6807    /// header read, no fault.
6808    pub fn has_labels(&self) -> bool {
6809        self.header.label_index_section().is_some() || self.header.pyramid_meta_len > 0
6810    }
6811
6812    /// Whether the file carries a LABEL_INDEX section (kind 8).
6813    pub fn has_label_index(&self) -> bool {
6814        self.header.label_index_section().is_some()
6815    }
6816
6817    /// The file header (already fetched by [`open_ranged`](Self::open_ranged)).
6818    pub fn header(&self) -> &Header {
6819        &self.header
6820    }
6821
6822    /// Whether the file carries a TEXT_INDEX section — a header read, no fault.
6823    pub fn has_text_index(&self) -> bool {
6824        self.header.text_index_len > 0
6825    }
6826
6827    /// Whether the file carries a pyramid (where the label index lives) — a
6828    /// header read, no fault.
6829    pub fn has_pyramid(&self) -> bool {
6830        self.header.pyramid_meta_len > 0
6831    }
6832
6833    /// Full-text search — see [`Rete::text_search`] for the semantics. Faults
6834    /// the token table on the first call, then only the queried postings.
6835    pub fn text_search(&self, words: &[&str], prefix: Option<&str>, limit: usize) -> Vec<String> {
6836        let Some(dict) = self.dict.get() else {
6837            return Vec::new();
6838        };
6839        text_search_in(self.text_index.index(), dict, words, prefix, limit)
6840    }
6841
6842    /// What that first search will cost — see
6843    /// [`Rete::text_index_token_table_len`]. One ≤10-byte range read, so a
6844    /// caller can quote the figure before deciding to search.
6845    pub fn text_index_token_table_len(&self) -> Option<u64> {
6846        self.text_index.token_table_len()
6847    }
6848
6849    /// Label-prefix search — see [`Rete::prefix_search`]. Reads the
6850    /// LABEL_INDEX section when the file has one; otherwise faults the pyramid
6851    /// (which carries the legacy label block), the pricier path.
6852    pub fn prefix_search(&self, prefix: &str, limit: usize) -> Vec<(String, String)> {
6853        if let Some(li) = self.labels.get() {
6854            return li.prefix_search(prefix, limit);
6855        }
6856        let pyr = match &self.pyramid {
6857            PyramidSlot::Resident(p) => p.as_ref(),
6858            PyramidSlot::Lazy { loader, cell } => cell.get_or_init(loader).as_ref(),
6859        };
6860        let (Some(pyr), Some(dict)) = (pyr, self.dict.get()) else {
6861            return Vec::new();
6862        };
6863        resolve_label_hits(dict, pyr.prefix_search(prefix, limit))
6864    }
6865}
6866
6867/// A lightweight, overview-only view of a file: the pyramid summary graph plus
6868/// just enough dictionary to label predicates. Fetched via ranges *without*
6869/// touching the (large) triple index — the "load the coarse graph first" path
6870/// from SPEC.md §7.2.
6871#[must_use]
6872pub struct SummaryView {
6873    pub round: u32,
6874    pub summary: Vec<SuperEdge>,
6875    /// The shipped `subClassOf` hierarchy (v2 schema pyramid; empty on v1 files).
6876    pub class_hierarchy: Vec<ClassNode>,
6877    /// Per-level type rollups — the leveled legend, read index-free.
6878    pub level_rollups: Vec<LevelRollup>,
6879    /// Per-level lateral class-relation graph (the non-`is-a` connections).
6880    pub level_links: Vec<LevelLinks>,
6881    /// Per-community descriptors (Phase 4 progressive refinement; may be empty).
6882    pub descriptors: Vec<CommunityDescriptor>,
6883    /// `subClassOf` cycles (v2.1; empty on older files).
6884    pub subclass_cycles: Vec<Vec<String>>,
6885    /// `owl:disjointWith` class pairs (v2.1; empty on older files).
6886    pub disjoint_pairs: Vec<(String, String)>,
6887    /// `owl:equivalentClass` class pairs (v2.1; empty on older files).
6888    pub equivalent_pairs: Vec<(String, String)>,
6889    dict: Dictionary,
6890}
6891
6892impl SummaryView {
6893    /// Read header → dictionary → pyramid-meta only (skips the index container).
6894    pub fn open_ranged<R: RangeReader>(reader: &R) -> Result<Option<Self>, FileError> {
6895        let head = reader.read_at(0, (HEADER_LEN as u64).min(reader.len()))?;
6896        let header = Header::from_bytes(&head)?;
6897        if header.pyramid_meta_len == 0 {
6898            return Ok(None);
6899        }
6900        if header.version == crate::v06::VERSION {
6901            return Self::open_ranged_v06(reader, &header, head).map(Some);
6902        }
6903
6904        let dict_bytes = reader.read_at(header.dictionary_offset, header.dictionary_len)?;
6905        let dict = decode_dictionary_container(&dict_bytes, header.dict_codec)?;
6906
6907        let mb = reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?;
6908        let mut meta =
6909            PyramidMeta::decode(&mb).map_err(|_| FileError::Container("malformed pyramid meta"))?;
6910        // A file whose schema moved to its own section (kind 9) keeps only the
6911        // community material here: take the schema fields from that section.
6912        if let Some(s) = read_schema_with_header(reader, &header) {
6913            meta.class_hierarchy = s.class_hierarchy;
6914            meta.level_rollups = s.level_rollups;
6915            meta.level_links = s.level_links;
6916            meta.subclass_cycles = s.subclass_cycles;
6917            meta.disjoint_pairs = s.disjoint_pairs;
6918            meta.equivalent_pairs = s.equivalent_pairs;
6919        }
6920
6921        Ok(Some(SummaryView {
6922            round: meta.round,
6923            summary: meta.summary,
6924            class_hierarchy: meta.class_hierarchy,
6925            level_rollups: meta.level_rollups,
6926            level_links: meta.level_links,
6927            descriptors: meta.descriptors,
6928            subclass_cycles: meta.subclass_cycles,
6929            disjoint_pairs: meta.disjoint_pairs,
6930            equivalent_pairs: meta.equivalent_pairs,
6931            dict,
6932        }))
6933    }
6934
6935    /// [`open_ranged`](Self::open_ranged) for a `0x06` file: the pyramid
6936    /// section, the schema, and the dictionary (its leaves and chunks read
6937    /// whole, as the `0x05` path reads the whole dictionary container).
6938    fn open_ranged_v06<R: RangeReader>(
6939        reader: &R,
6940        header: &Header,
6941        head: Vec<u8>,
6942    ) -> Result<Self, FileError> {
6943        use crate::v06::{kind, open};
6944        // One first read, then every range the view needs past it — the
6945        // pyramid, the schema, the dictionary's leaves and chunks — in one
6946        // batch.
6947        let mut first = head;
6948        let want_first = crate::v06::FIRST_READ.min(reader.len());
6949        if want_first > first.len() as u64 {
6950            let at = first.len() as u64;
6951            first.extend_from_slice(&reader.read_at(at, want_first - at)?);
6952        }
6953        let pre = open::Prefixed {
6954            first: &first,
6955            r: reader,
6956        };
6957        let (h, root) = open::read_front(&pre)?;
6958        let want: Vec<ByteRange> = [
6959            kind::PYRAMID,
6960            kind::SCHEMA,
6961            kind::DICT_LEAVES,
6962            kind::DICT_DATA,
6963        ]
6964        .iter()
6965        .filter_map(|&k| root.section(k).and_then(|s| s.abs(&h).ok()))
6966        .collect();
6967        let sparse = open::SparseReader::fetch(&pre, first.clone(), &want)?;
6968        let mb = open::read_section(&sparse, &h, &root, kind::PYRAMID)?
6969            .ok_or(FileError::Container("0x06 file has no pyramid"))?;
6970        let mut meta =
6971            PyramidMeta::decode(&mb).map_err(|_| FileError::Container("malformed pyramid meta"))?;
6972        if let Some(s) = read_schema_with_header(&sparse, header) {
6973            meta.class_hierarchy = s.class_hierarchy;
6974            meta.level_rollups = s.level_rollups;
6975            meta.level_links = s.level_links;
6976            meta.subclass_cycles = s.subclass_cycles;
6977            meta.disjoint_pairs = s.disjoint_pairs;
6978            meta.equivalent_pairs = s.equivalent_pairs;
6979        }
6980        let f = open::V06File::open(std::sync::Arc::new(sparse), &first)?;
6981        let dict = f.dictionary([true; 4])?;
6982        Ok(SummaryView {
6983            round: meta.round,
6984            summary: meta.summary,
6985            class_hierarchy: meta.class_hierarchy,
6986            level_rollups: meta.level_rollups,
6987            level_links: meta.level_links,
6988            descriptors: meta.descriptors,
6989            subclass_cycles: meta.subclass_cycles,
6990            disjoint_pairs: meta.disjoint_pairs,
6991            equivalent_pairs: meta.equivalent_pairs,
6992            dict,
6993        })
6994    }
6995
6996    /// A schema-only view of a file **without a pyramid**, from its SCHEMA
6997    /// section (kind 9): the hierarchy, levels and axioms, with an empty
6998    /// community summary. Reads the header and the SCHEMA section only — the
6999    /// section carries its own class and predicate strings, so no dictionary.
7000    /// `Ok(None)` when the file has no SCHEMA section. Callers that need the
7001    /// summary (`cost`, federation source selection, progressive queries) use
7002    /// [`open_ranged`](Self::open_ranged), which keeps requiring a pyramid.
7003    pub fn open_schema_ranged<R: RangeReader>(reader: &R) -> Result<Option<Self>, FileError> {
7004        let head = reader.read_at(0, HEADER_LEN as u64)?;
7005        let header = Header::from_bytes(&head)?;
7006        let Some(s) = read_schema_with_header(reader, &header) else {
7007            return Ok(None);
7008        };
7009        let dict = crate::DictionaryBuilder::new().build();
7010        Ok(Some(SummaryView {
7011            round: 0,
7012            summary: Vec::new(),
7013            class_hierarchy: s.class_hierarchy,
7014            level_rollups: s.level_rollups,
7015            level_links: s.level_links,
7016            descriptors: Vec::new(),
7017            subclass_cycles: s.subclass_cycles,
7018            disjoint_pairs: s.disjoint_pairs,
7019            equivalent_pairs: s.equivalent_pairs,
7020            dict,
7021        }))
7022    }
7023
7024    /// Number of semantic-zoom levels in the schema pyramid (0 if none shipped).
7025    pub fn level_count(&self) -> usize {
7026        self.level_rollups.len()
7027    }
7028
7029    /// The type rollup at semantic level `k` (0 = coarsest/most abstract), or
7030    /// `None` if `k` is out of range. Index-free — answered from the pyramid-meta.
7031    pub fn level_rollup(&self, k: usize) -> Option<&LevelRollup> {
7032        self.level_rollups.get(k)
7033    }
7034
7035    /// Resolve a predicate ID in the summary to its term.
7036    pub fn predicate_term(&self, id: u32) -> Option<String> {
7037        self.dict.predicate_term(id)
7038    }
7039
7040    /// Exact number of triples using `predicate`, summed from the summary's
7041    /// superedge counts — answered without ever reading the triple index.
7042    pub fn predicate_total(&self, predicate: &str) -> u32 {
7043        match self.dict.predicate_id(predicate) {
7044            Some(pid) => self
7045                .summary
7046                .iter()
7047                .filter(|e| e.predicate == pid)
7048                .map(|e| e.count)
7049                .sum(),
7050            None => 0,
7051        }
7052    }
7053
7054    /// All predicates with their exact triple totals, descending by count.
7055    pub fn predicate_totals(&self) -> Vec<(String, u32)> {
7056        let mut by_pred: std::collections::BTreeMap<u32, u32> = std::collections::BTreeMap::new();
7057        for e in &self.summary {
7058            *by_pred.entry(e.predicate).or_default() += e.count;
7059        }
7060        let mut out: Vec<(String, u32)> = by_pred
7061            .into_iter()
7062            .filter_map(|(pid, c)| self.dict.predicate_term(pid).map(|t| (t, c)))
7063            .collect();
7064        out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
7065        out
7066    }
7067
7068    /// Number of communities the summary spans (distinct supernode endpoints).
7069    pub fn community_count(&self) -> usize {
7070        let mut comms = std::collections::BTreeSet::new();
7071        for e in &self.summary {
7072            comms.insert(e.s_comm);
7073            comms.insert(e.o_comm);
7074        }
7075        comms.len()
7076    }
7077
7078    /// **Index-free T-Box coherence (Tier-0).** Detect schema-level incoherent
7079    /// points purely from the shipped schema pyramid — no triple index, no
7080    /// instance data, O(ontology) regardless of graph size:
7081    /// - `subclass-cycle`: a set of classes that are mutually `rdfs:subClassOf`.
7082    /// - `unsatisfiable-class`: a class whose ancestor closure (over all parents,
7083    ///   folded through `owl:equivalentClass`) contains both ends of an
7084    ///   `owl:disjointWith` pair, so no individual can ever be one.
7085    ///
7086    /// Soundness is bounded by what the pyramid ships: the `subClassOf` hierarchy
7087    /// is capped (`MAX_HIERARCHY` in `schema_pyramid`), so on a very large ontology
7088    /// a pruned ancestor can hide an unsatisfiable class (a false *coherent*, never
7089    /// a false *incoherent*). Instance-level clashes (a node typed into disjoint
7090    /// classes, functional-property clashes) are NOT visible here — they need the
7091    /// A-Box (Tier-1/Tier-2 `reason`).
7092    pub fn tbox_coherence(&self) -> Vec<crate::reason::Inconsistency> {
7093        schema_coherence(
7094            &self.class_hierarchy,
7095            &self.subclass_cycles,
7096            &self.disjoint_pairs,
7097            &self.equivalent_pairs,
7098        )
7099    }
7100
7101    /// True when [`tbox_coherence`](Self::tbox_coherence) finds no schema-level
7102    /// incoherent point.
7103    pub fn tbox_is_coherent(&self) -> bool {
7104        self.tbox_coherence().is_empty()
7105    }
7106}
7107
7108/// Compute T-Box coherence points from the schema-pyramid fields alone — no
7109/// dictionary, no index, no instance data. Shared by [`SummaryView::tbox_coherence`]
7110/// and the dictionary-free [`read_schema_coherence_ranged`]. Emits `subclass-cycle`
7111/// and `unsatisfiable-class` (a class whose ancestor closure — over all parents,
7112/// folded through `owl:equivalentClass` — contains both ends of a disjoint pair).
7113pub fn schema_coherence(
7114    class_hierarchy: &[ClassNode],
7115    subclass_cycles: &[Vec<String>],
7116    disjoint_pairs: &[(String, String)],
7117    equivalent_pairs: &[(String, String)],
7118) -> Vec<crate::reason::Inconsistency> {
7119    use crate::reason::Inconsistency;
7120    use std::collections::{BTreeMap, BTreeSet, VecDeque};
7121    const MAX_REACH: usize = 100_000;
7122
7123    let mut out: Vec<Inconsistency> = Vec::new();
7124
7125    for cyc in subclass_cycles {
7126        let detail = if cyc.len() == 1 {
7127            format!("{} is rdfs:subClassOf itself (a cycle)", cyc[0])
7128        } else {
7129            format!(
7130                "classes {{{}}} are mutually rdfs:subClassOf (a cycle)",
7131                cyc.join(", ")
7132            )
7133        };
7134        out.push(Inconsistency {
7135            kind: "subclass-cycle",
7136            detail,
7137        });
7138    }
7139
7140    if !disjoint_pairs.is_empty() {
7141        // Upward adjacency: subClassOf parents + bidirectional equivalence.
7142        let mut adj: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
7143        for n in class_hierarchy {
7144            let e = adj.entry(n.class.as_str()).or_default();
7145            for p in &n.parents {
7146                e.push(p.as_str());
7147            }
7148        }
7149        for (a, b) in equivalent_pairs {
7150            adj.entry(a.as_str()).or_default().push(b.as_str());
7151            adj.entry(b.as_str()).or_default().push(a.as_str());
7152        }
7153
7154        // Candidate focus classes: every class named anywhere in the schema.
7155        let mut focuses: BTreeSet<&str> =
7156            class_hierarchy.iter().map(|n| n.class.as_str()).collect();
7157        for (a, b) in disjoint_pairs.iter().chain(equivalent_pairs) {
7158            focuses.insert(a.as_str());
7159            focuses.insert(b.as_str());
7160        }
7161
7162        let mut seen: BTreeSet<&str> = BTreeSet::new();
7163        for &c in &focuses {
7164            // reach(c) = {c} ∪ ancestors (capped BFS over `adj`).
7165            let mut reach: BTreeSet<&str> = BTreeSet::new();
7166            let mut q: VecDeque<&str> = VecDeque::new();
7167            reach.insert(c);
7168            q.push_back(c);
7169            while let Some(x) = q.pop_front() {
7170                if reach.len() > MAX_REACH {
7171                    break;
7172                }
7173                if let Some(ns) = adj.get(x) {
7174                    for &p in ns {
7175                        if reach.insert(p) {
7176                            q.push_back(p);
7177                        }
7178                    }
7179                }
7180            }
7181            for (x, y) in disjoint_pairs {
7182                if reach.contains(x.as_str()) && reach.contains(y.as_str()) && seen.insert(c) {
7183                    out.push(Inconsistency {
7184                        kind: "unsatisfiable-class",
7185                        detail: format!(
7186                            "{c} is a subclass of both {x} and {y}, which are \
7187                             owl:disjointWith — no individual can be a {c}"
7188                        ),
7189                    });
7190                    break;
7191                }
7192            }
7193        }
7194    }
7195
7196    out.sort_by(|a, b| (a.kind, &a.detail).cmp(&(b.kind, &b.detail)));
7197    out
7198}
7199
7200/// **Dictionary-free Tier-0 coherence read.** Fetch only the header and the
7201/// pyramid-meta range (2 small range reads) and run [`schema_coherence`] over the
7202/// schema pyramid — never touching the **dictionary** (which a literal-heavy file
7203/// makes large) or the triple index. `Ok(None)` if the file ships no pyramid.
7204///
7205/// This is what makes the Tier-0 check cheap on big graphs: the schema pyramid
7206/// carries its own class-string table, so coherence needs none of the dictionary.
7207pub fn read_schema_coherence_ranged<R: RangeReader>(
7208    reader: &R,
7209) -> Result<Option<Vec<crate::reason::Inconsistency>>, FileError> {
7210    let head = reader.read_at(0, HEADER_LEN as u64)?;
7211    let header = Header::from_bytes(&head)?;
7212    // The SCHEMA section (kind 9) first: one range, and no pyramid needed.
7213    if let Some(s) = read_schema_with_header(reader, &header) {
7214        return Ok(Some(schema_coherence(
7215            &s.class_hierarchy,
7216            &s.subclass_cycles,
7217            &s.disjoint_pairs,
7218            &s.equivalent_pairs,
7219        )));
7220    }
7221    if header.pyramid_meta_len == 0 {
7222        return Ok(None);
7223    }
7224    // Fast path: the header records the trailing schema block's length, so read ONLY
7225    // that block (at the end of pyramid-meta) — never the community summary, the
7226    // dictionary, or the index. This is what makes Tier-0 flat at any graph size.
7227    if header.schema_meta_len > 0 && (header.schema_meta_len as u64) <= header.pyramid_meta_len {
7228        let off =
7229            header.pyramid_meta_offset + header.pyramid_meta_len - header.schema_meta_len as u64;
7230        let block = reader.read_at(off, header.schema_meta_len as u64)?;
7231        let (hierarchy, cycles, disjoint, equivalent) = crate::meta::decode_schema_block(&block)
7232            .map_err(|_| FileError::Container("malformed schema block"))?;
7233        return Ok(Some(schema_coherence(
7234            &hierarchy,
7235            &cycles,
7236            &disjoint,
7237            &equivalent,
7238        )));
7239    }
7240    // Fallback (pre-v0.2.1 files with no header field): decode the whole pyramid-meta.
7241    let mb = if header.version == crate::v06::VERSION {
7242        crate::v06::open::read_pyramid(reader)?.unwrap_or_default()
7243    } else {
7244        reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?
7245    };
7246    let meta =
7247        PyramidMeta::decode(&mb).map_err(|_| FileError::Container("malformed pyramid meta"))?;
7248    Ok(Some(schema_coherence(
7249        &meta.class_hierarchy,
7250        &meta.subclass_cycles,
7251        &meta.disjoint_pairs,
7252        &meta.equivalent_pairs,
7253    )))
7254}
7255
7256/// The **schema summary** (per-class histogram + class relations at the finest
7257/// level) read over a [`RangeReader`] from the schema pyramid alone — the
7258/// index-free, range-readable source for a Schema view of a remote graph. Returns
7259/// `(classes, relations)` with `classes = [(class_iri, count)]` and `relations =
7260/// [(s_class, predicate, o_class, count)]`; `None` when the file has no schema
7261/// pyramid. Like [`read_schema_coherence_ranged`], it reads only the trailing
7262/// schema block, so it stays flat at any graph size.
7263#[allow(clippy::type_complexity)]
7264pub fn read_schema_summary_ranged<R: RangeReader>(
7265    reader: &R,
7266) -> Result<Option<(Vec<(String, u64)>, Vec<(String, String, String, u64)>)>, FileError> {
7267    let head = reader.read_at(0, HEADER_LEN as u64)?;
7268    let header = Header::from_bytes(&head)?;
7269    // The SCHEMA section (kind 9) first: one range, and no pyramid needed.
7270    let meta = if let Some(s) = read_schema_with_header(reader, &header) {
7271        PyramidMeta::new(0, Vec::new(), &[]).with_schema(
7272            Vec::new(),
7273            s.level_rollups,
7274            s.level_links,
7275            Vec::new(),
7276            Vec::new(),
7277            Vec::new(),
7278            Vec::new(),
7279        )
7280    } else {
7281        if header.pyramid_meta_len == 0 {
7282            return Ok(None);
7283        }
7284        if header.schema_meta_len > 0 && (header.schema_meta_len as u64) <= header.pyramid_meta_len
7285        {
7286            let off = header.pyramid_meta_offset + header.pyramid_meta_len
7287                - header.schema_meta_len as u64;
7288            let block = reader.read_at(off, header.schema_meta_len as u64)?;
7289            let summary = crate::meta::decode_schema_block_summary(&block)
7290                .map_err(|_| FileError::Container("malformed schema block"))?;
7291            return Ok(Some(summary));
7292        }
7293        // Fallback (pre-v0.2.1 files): decode the whole pyramid-meta, pull finest levels.
7294        let mb = if header.version == crate::v06::VERSION {
7295            crate::v06::open::read_pyramid(reader)?.unwrap_or_default()
7296        } else {
7297            reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?
7298        };
7299        PyramidMeta::decode(&mb).map_err(|_| FileError::Container("malformed pyramid meta"))?
7300    };
7301    if meta.level_rollups.is_empty() && meta.level_links.is_empty() {
7302        return Ok(None);
7303    }
7304    let classes = meta
7305        .level_rollups
7306        .iter()
7307        .max_by_key(|r| r.depth)
7308        .map(|r| r.classes.clone())
7309        .unwrap_or_default();
7310    let relations = meta
7311        .level_links
7312        .iter()
7313        .max_by_key(|l| l.depth)
7314        .map(|l| {
7315            l.links
7316                .iter()
7317                .map(|c| {
7318                    (
7319                        c.s_class.clone(),
7320                        c.predicate.clone(),
7321                        c.o_class.clone(),
7322                        c.count,
7323                    )
7324                })
7325                .collect()
7326        })
7327        .unwrap_or_default();
7328    Ok(Some((classes, relations)))
7329}
7330
7331/// Every dictionary section's chunk **routing keys**, exactly as stored, from a
7332/// finished file image. Test-only: it exists so a test in another module of
7333/// this crate (`extbuild`) can assert on the directory the writers produce
7334/// without any of this becoming public surface.
7335#[cfg(test)]
7336pub(crate) fn dict_chunk_keys_for_test(image: &[u8]) -> Vec<Vec<Vec<u8>>> {
7337    if crate::v06::open::is_v06(image) {
7338        // 0x06: the routing keys sit in the leaf directories of the dictionary.
7339        use crate::v06::{decode_chunk_leaf, decode_dict_root, kind, open, tag};
7340        let r = crate::reader::SliceReader::new(image);
7341        let (h, root) = open::read_front(&r).unwrap();
7342        let roots = decode_dict_root(root.unit(tag::DICT).unwrap()).unwrap();
7343        let leaves = open::read_section(&r, &h, &root, kind::DICT_LEAVES)
7344            .unwrap()
7345            .unwrap();
7346        return roots
7347            .iter()
7348            .map(|s| {
7349                s.leaves
7350                    .iter()
7351                    .flat_map(|(l, _, _, _)| {
7352                        let stored = &leaves[l.off as usize..(l.off + l.len) as usize];
7353                        let raw = decompress(h.internal_codec, stored).unwrap();
7354                        decode_chunk_leaf(&raw, l.n).unwrap()
7355                    })
7356                    .map(|c| c.key)
7357                    .collect()
7358            })
7359            .collect();
7360    }
7361    let header = Header::from_bytes(&image[..HEADER_LEN]).unwrap();
7362    let s = header.dictionary_offset as usize;
7363    let e = s + header.dictionary_len as usize;
7364    decode_container(&image[s..e], CODEC_NONE)
7365        .unwrap()
7366        .iter()
7367        .map(|payload| {
7368            let (_meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64).unwrap();
7369            entries.into_iter().map(|c| c.key).collect()
7370        })
7371        .collect()
7372}
7373
7374#[cfg(test)]
7375mod tests {
7376    use super::*;
7377    use super::{
7378        write_dataset_v05 as write_dataset,
7379        write_dataset_with_metadata_v05 as write_dataset_with_metadata,
7380        write_file_v05 as write_file,
7381    };
7382    use crate::dictionary::DictionaryBuilder;
7383    use crate::index::GraphIndexBuilder;
7384
7385    /// The pure-Rust decode path (wasm, `--no-default-features`) reads a zstd
7386    /// frame back byte for byte. Guards the `ruzstd` API across upgrades.
7387    #[cfg(not(feature = "compression"))]
7388    #[test]
7389    fn ruzstd_decodes_a_zstd_frame() {
7390        let data: Vec<u8> = (0..50_000u32)
7391            .flat_map(|i| (i % 251).to_le_bytes())
7392            .collect();
7393        let frame = ruzstd::encoding::compress_to_vec(
7394            &data[..],
7395            ruzstd::encoding::CompressionLevel::Fastest,
7396        );
7397        assert!(frame.len() < data.len());
7398        assert_eq!(decompress(CODEC_ZSTD, &frame).unwrap(), data);
7399    }
7400
7401    /// Bounded decompression: a frame whose header declares more than the
7402    /// bound is refused before decoding; one that states no size is decoded at
7403    /// most one byte past the bound; one that lies about its size fails; and
7404    /// the size a header states is read without decoding.
7405    #[cfg(feature = "compression")]
7406    #[test]
7407    fn decompression_is_bounded() {
7408        let data: Vec<u8> = (0..100_000u32).map(|i| (i % 251) as u8).collect();
7409        let sized = zstd::bulk::compress(&data, 3).unwrap();
7410        assert_eq!(zstd_frame_content_size(&sized), Some(100_000));
7411        assert_eq!(block_raw_len(CODEC_ZSTD, &sized, 1 << 20).unwrap(), 100_000);
7412        assert_eq!(
7413            decompress_bounded(CODEC_ZSTD, &sized, 100_000).unwrap(),
7414            data
7415        );
7416        assert!(decompress_bounded(CODEC_ZSTD, &sized, 99_999).is_err());
7417        // A streamed frame states no size: bounded by decoding.
7418        let stream = zstd::encode_all(&data[..], 3).unwrap();
7419        assert_eq!(zstd_frame_content_size(&stream), None);
7420        assert_eq!(
7421            block_raw_len(CODEC_ZSTD, &stream, 1 << 20).unwrap(),
7422            100_000
7423        );
7424        assert_eq!(
7425            decompress_bounded(CODEC_ZSTD, &stream, 100_000).unwrap(),
7426            data
7427        );
7428        assert!(decompress_bounded(CODEC_ZSTD, &stream, 50_000).is_err());
7429        // A header that under-states its content fails instead of growing.
7430        let mut liar = sized.clone();
7431        let fhd = liar[4];
7432        let fcs_at = 5 + usize::from(fhd & 0x20 == 0);
7433        liar[fcs_at] = liar[fcs_at].wrapping_sub(1);
7434        assert!(decompress_bounded(CODEC_ZSTD, &liar, 200_000).is_err());
7435        // Uncompressed blocks: the length is the size.
7436        assert!(decompress_bounded(CODEC_NONE, &data, 99_999).is_err());
7437        assert_eq!(
7438            decompress_bounded(CODEC_NONE, &data, 100_000).unwrap(),
7439            data
7440        );
7441        assert_eq!(zstd_frame_content_size(b"RETE"), None);
7442    }
7443
7444    /// A three-permutation file must survive every opener — resident, ranged,
7445    /// ranged-lazy — and answer every pattern shape with the rows the
7446    /// six-permutation twin gives, including through a named graph's own
7447    /// container. The mask is a file-level fact, so getting it to the named
7448    /// graphs' decoder is a separate thing that can be forgotten.
7449    #[test]
7450    fn three_permutation_file_round_trips_every_open_path() {
7451        use crate::index::PermSet;
7452        use crate::reader::SliceReader;
7453
7454        let build = |perms: PermSet| {
7455            let mut db = crate::DictionaryBuilder::new();
7456            let mut triples = Vec::new();
7457            for i in 0..60u32 {
7458                let (s, p, o) = (
7459                    format!("<http://ex/s{}>", i % 11),
7460                    format!("<http://ex/p{}>", i % 3),
7461                    format!("<http://ex/o{}>", i % 7),
7462                );
7463                db.observe(&s, &p, &o);
7464                triples.push((s, p, o));
7465            }
7466            let dict = db.build();
7467            let ids: Vec<(u32, u32, u32)> = triples
7468                .iter()
7469                .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
7470                .collect();
7471            let def = GraphIndexBuilder::from_triples(ids.clone())
7472                .with_tile_budget(96)
7473                .with_perms(perms)
7474                .build();
7475            let named = GraphIndexBuilder::from_triples(ids)
7476                .with_tile_budget(96)
7477                .with_perms(perms)
7478                .build();
7479            write_dataset(
7480                &dict,
7481                &def,
7482                &[("<http://ex/g>".to_string(), named)],
7483                true,
7484                &[],
7485                0,
7486            )
7487        };
7488
7489        let six = build(PermSet::ALL);
7490        let three = build(PermSet::CORE);
7491        assert!(three.len() < six.len());
7492        assert_eq!(Header::from_bytes(&three).unwrap().perms, PermSet::CORE);
7493        assert_eq!(Header::from_bytes(&six).unwrap().perms, PermSet::ALL);
7494
7495        let shapes: Vec<(Option<&str>, Option<&str>, Option<&str>)> = vec![
7496            (None, None, None),
7497            (Some("<http://ex/s3>"), None, None),
7498            (None, Some("<http://ex/p1>"), None),
7499            (None, None, Some("<http://ex/o5>")),
7500            (Some("<http://ex/s3>"), Some("<http://ex/p1>"), None),
7501            (Some("<http://ex/s3>"), None, Some("<http://ex/o5>")),
7502            (None, Some("<http://ex/p1>"), Some("<http://ex/o5>")),
7503            (
7504                Some("<http://ex/s3>"),
7505                Some("<http://ex/p1>"),
7506                Some("<http://ex/o5>"),
7507            ),
7508        ];
7509
7510        for (image, tag) in [(&six, "six"), (&three, "three")] {
7511            let resident = Rete::open(image).unwrap();
7512            let leaked: &'static [u8] = Box::leak(image.clone().into_boxed_slice());
7513            let ranged = Rete::open_ranged(&SliceReader::new(leaked)).unwrap();
7514            let lazy =
7515                Rete::open_ranged_lazy_with(SliceReader::new(leaked), HEADER_LEN as u64).unwrap();
7516            for (s, p, o) in &shapes {
7517                let want: Vec<_> = {
7518                    let mut v = Rete::open(&six).unwrap().query(*s, *p, *o);
7519                    v.sort();
7520                    v
7521                };
7522                for (got, path) in [
7523                    (resident.query(*s, *p, *o), "resident"),
7524                    (ranged.query(*s, *p, *o), "ranged"),
7525                    (lazy.query(*s, *p, *o), "lazy"),
7526                ] {
7527                    let mut got = got;
7528                    got.sort();
7529                    assert_eq!(got, want, "{tag}/{path} disagreed on {:?}", (s, p, o));
7530                }
7531                // The named graph's container carries the same mask.
7532                let mut g = resident.query_in_graph(Some("<http://ex/g>"), *s, *p, *o);
7533                g.sort();
7534                assert_eq!(g, want, "{tag}/named disagreed on {:?}", (s, p, o));
7535            }
7536        }
7537
7538        // And the single-pattern ROUTED read (the `query-url` path) locates its
7539        // section by container position, not by the format's fixed six-wide
7540        // slot — the one place the two indexes differ.
7541        let leaked: &'static [u8] = Box::leak(three.clone().into_boxed_slice());
7542        let reader = SliceReader::new(leaked);
7543        let routed = Rete::query_ranged(&reader, None, None, Some("<http://ex/o5>")).unwrap();
7544        let mut want = Rete::open(&six)
7545            .unwrap()
7546            .query(None, None, Some("<http://ex/o5>"));
7547        want.sort();
7548        let mut got = routed;
7549        got.sort();
7550        assert_eq!(got, want, "routed single-pattern read on a lean file");
7551    }
7552
7553    #[test]
7554    fn read_coalesced_merges_within_gap_and_splits_beyond() {
7555        use crate::reader::{CountingReader, SliceReader};
7556        let bytes = vec![0u8; 4096];
7557        // Three 16-byte ranges: A..B gap = 32, B..C gap = 1024.
7558        let ranges = [
7559            ByteRange { offset: 0, len: 16 },
7560            ByteRange {
7561                offset: 48,
7562                len: 16,
7563            },
7564            ByteRange {
7565                offset: 1088,
7566                len: 16,
7567            },
7568        ];
7569        // Tight gap (16): nothing merges → one read per range.
7570        let r = CountingReader::new(SliceReader::new(&bytes));
7571        let out = read_coalesced(&r, &ranges, 16).unwrap();
7572        assert_eq!(out.len(), 3);
7573        assert_eq!(r.requests(), 3);
7574        // Gap 64 merges A+B (gap 32) but not C (gap 1024) → two reads.
7575        let r = CountingReader::new(SliceReader::new(&bytes));
7576        read_coalesced(&r, &ranges, 64).unwrap();
7577        assert_eq!(r.requests(), 2);
7578        // Gap 4096 merges all three into one read, over-fetching the gaps.
7579        let r = CountingReader::new(SliceReader::new(&bytes));
7580        read_coalesced(&r, &ranges, 4096).unwrap();
7581        assert_eq!(r.requests(), 1);
7582    }
7583
7584    fn build_image() -> Vec<u8> {
7585        let triples = [
7586            ("Alice", "knows", "Bob"),
7587            ("Bob", "knows", "Carol"),
7588            ("Alice", "age", "30"),
7589        ];
7590        let mut db = DictionaryBuilder::new();
7591        for (s, p, o) in triples {
7592            db.observe(s, p, o);
7593        }
7594        let dict = db.build();
7595
7596        let mut ib = GraphIndexBuilder::new();
7597        for (s, p, o) in triples {
7598            ib.push(dict.encode(s, p, o).unwrap());
7599        }
7600        let index = ib.build();
7601
7602        let (meta, levels) = build_pyramid_meta(&dict, &triples_ids(&dict), DEFAULT_TILE_BUDGET);
7603        write_file(&dict, &index, false, &meta, levels)
7604    }
7605
7606    fn triples_ids(dict: &Dictionary) -> Vec<(u32, u32, u32)> {
7607        [
7608            ("Alice", "knows", "Bob"),
7609            ("Bob", "knows", "Carol"),
7610            ("Alice", "age", "30"),
7611        ]
7612        .iter()
7613        .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
7614        .collect()
7615    }
7616
7617    #[test]
7618    fn file_round_trips_header_and_counts() {
7619        let bytes = build_image();
7620        let rete = Rete::open(&bytes).unwrap();
7621        assert_eq!(rete.header().quad_count, 3);
7622        assert!(rete.header().term_count >= 5);
7623        let expected_codec = writer_codec();
7624        assert_eq!(rete.header().dict_codec, expected_codec);
7625        assert_eq!(rete.header().block_codec, expected_codec);
7626        assert_eq!(&bytes[bytes.len() - 4..], &MAGIC); // footer marker
7627    }
7628
7629    /// A file whose index was built with a tiny tile budget (forcing many
7630    /// tiles per permutation) must round-trip through write/open and answer
7631    /// every query shape identically — through both the in-memory and the
7632    /// routed ranged read paths.
7633    #[test]
7634    fn multi_tile_file_round_trips_and_routes() {
7635        let triples: Vec<(String, String, String)> = (0..200)
7636            .map(|i| {
7637                (
7638                    format!("<http://ex/s/{i}>"),
7639                    format!("<http://ex/p/{}>", i % 5),
7640                    format!("<http://ex/o/{}>", i % 23),
7641                )
7642            })
7643            .collect();
7644        let mut db = DictionaryBuilder::new();
7645        for (s, p, o) in &triples {
7646            db.observe(s, p, o);
7647        }
7648        let dict = db.build();
7649        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
7650        for (s, p, o) in &triples {
7651            ib.push(dict.encode(s, p, o).unwrap());
7652        }
7653        let index = ib.build();
7654        assert!(
7655            index.tile_sections()[0].len() > 3,
7656            "tiny budget must force many tiles"
7657        );
7658        let bytes = write_file(&dict, &index, false, &[], 0);
7659
7660        let rete = Rete::open(&bytes).unwrap();
7661        assert_eq!(rete.header().version, crate::header::LEGACY_FORMAT_VERSION);
7662        assert_eq!(rete.query(None, None, None).len(), 200);
7663        assert_eq!(rete.query(Some("<http://ex/s/7>"), None, None).len(), 1);
7664        assert_eq!(
7665            rete.query(None, Some("<http://ex/p/3>"), None).len(),
7666            40,
7667            "predicate extent spans tiles"
7668        );
7669        assert_eq!(
7670            rete.query(None, None, Some("<http://ex/o/22>")).len(),
7671            8 // 22, 45, 68, ... < 200
7672        );
7673
7674        // Routed ranged read must agree (and only decompress matching tiles).
7675        use crate::reader::SliceReader;
7676        let reader = SliceReader::new(&bytes);
7677        let routed = Rete::query_ranged(&reader, Some("<http://ex/s/7>"), None, None).unwrap();
7678        assert_eq!(routed.len(), 1);
7679        let routed = Rete::query_ranged(&reader, None, Some("<http://ex/p/3>"), None).unwrap();
7680        assert_eq!(routed.len(), 40);
7681        let routed = Rete::query_ranged(&reader, None, None, Some("<http://ex/o/22>")).unwrap();
7682        assert_eq!(routed.len(), 8);
7683    }
7684
7685    /// The tile-synopsis trailer round-trips through encode/parse, and each parsed
7686    /// synopsis is **exactly** the tile block's own b/c zone — so the directory
7687    /// can never prune a tile the tile itself would have matched.
7688    /// Section-internal byte offsets are u64: a directory whose tiles sit past
7689    /// 4 GiB must parse with exact offsets on EVERY platform. On wasm32 (32-bit
7690    /// usize) the old parse truncated a >4 GiB section length and rejected the
7691    /// tail ("dict chunk overruns section" on the first >4 GiB dictionary —
7692    /// crossref's 5.2 GB g.obj — the playground regression this guards).
7693    #[test]
7694    fn tile_directory_offsets_survive_past_4gib() {
7695        let mut dir = Vec::new();
7696        write_uvarint(&mut dir, 2); // two tiles
7697        write_uvarint(&mut dir, 5); // tile 1: Δmin_a
7698        write_uvarint(&mut dir, 0); //         span
7699        write_uvarint(&mut dir, 3 << 30); //   len = 3 GiB
7700        write_uvarint(&mut dir, 1); // tile 2: Δmin_a
7701        write_uvarint(&mut dir, 0);
7702        write_uvarint(&mut dir, 2 << 30); //   len = 2 GiB
7703        let total = dir.len() as u64 + (3u64 << 30) + (2u64 << 30) + 64;
7704        let entries = parse_tile_directory(&dir, total).unwrap();
7705        assert_eq!(entries.len(), 2);
7706        assert_eq!(entries[1].start, dir.len() as u64 + (3u64 << 30));
7707        assert!(
7708            entries[1].end > u32::MAX as u64,
7709            "tail tile sits past 4 GiB"
7710        );
7711        // a total smaller than the tiles must still reject the directory
7712        assert!(parse_tile_directory(&dir, 1 << 20).is_err());
7713    }
7714
7715    #[test]
7716    fn tile_synopsis_trailer_round_trips() {
7717        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
7718        for i in 0..200u32 {
7719            ib.push((i, i % 7, i % 13));
7720        }
7721        let index = ib.build();
7722        let tile_count = index.tile_sections()[0].len();
7723        assert!(tile_count > 3, "tiny budget forces many tiles");
7724
7725        let payload = encode_tiled_section(&index, 0, CODEC_NONE);
7726        let dir = parse_tile_directory(&payload, payload.len() as u64).unwrap();
7727        assert_eq!(dir.len(), tile_count);
7728        // The trailer sits past the last tile; the old directory parse stops there.
7729        let trailer_start = dir.iter().map(|e| e.end).max().unwrap();
7730        assert!(
7731            trailer_start < payload.len() as u64,
7732            "a trailer follows the tiles"
7733        );
7734        for e in &dir {
7735            assert!(
7736                e.end <= payload.len() as u64,
7737                "tiles still located within the payload"
7738            );
7739        }
7740        let syn = parse_tile_synopsis(&payload, trailer_start as usize, dir.len()).unwrap();
7741        for (e, (min_b, max_b, min_c, max_c)) in dir.iter().zip(syn) {
7742            let block = decompress(CODEC_NONE, &payload[e.start as usize..e.end as usize]).unwrap();
7743            let z = *crate::triples::TripleBlock::parse(&block).unwrap().zone();
7744            assert_eq!(
7745                (min_b, max_b, min_c, max_c),
7746                (z.min_b, z.max_b, z.min_c, z.max_c),
7747                "synopsis equals the tile's own zone"
7748            );
7749        }
7750    }
7751
7752    /// End-to-end safety: a synopsis-carrying file, opened **lazily** (range
7753    /// reads), must return exactly the brute-force answer for every pattern shape
7754    /// — the synopsis prune may never drop a real match.
7755    #[test]
7756    fn tile_synopsis_lazy_matches_reference_every_shape() {
7757        use crate::reader::{CountingReader, SliceReader};
7758        let triples: Vec<(String, String, String)> = (0..200u32)
7759            .map(|i| {
7760                (
7761                    format!("<http://ex/s/{i:04}>"),
7762                    format!("<http://ex/p/{}>", i % 7),
7763                    format!("<http://ex/o/{:04}>", i % 13),
7764                )
7765            })
7766            .collect();
7767        let mut db = DictionaryBuilder::new();
7768        for (s, p, o) in &triples {
7769            db.observe(s, p, o);
7770        }
7771        let dict = db.build();
7772        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
7773        for (s, p, o) in &triples {
7774            ib.push(dict.encode(s, p, o).unwrap());
7775        }
7776        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
7777
7778        let eager = Rete::open(&bytes).unwrap();
7779        assert!(
7780            eager.header().has_tile_synopsis(),
7781            "new files set the synopsis flag"
7782        );
7783
7784        // `open_ranged_lazy` needs a `'static` reader; leak the image (the test
7785        // process exits straight after).
7786        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
7787        let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
7788        let lazy = Rete::open_ranged_lazy_with(reader, HEADER_LEN as u64).unwrap();
7789
7790        let brute = |s: Option<&str>, p: Option<&str>, o: Option<&str>| {
7791            let mut v: Vec<(String, String, String)> = triples
7792                .iter()
7793                .filter(|(a, b, c)| {
7794                    s.is_none_or(|x| x == a) && p.is_none_or(|x| x == b) && o.is_none_or(|x| x == c)
7795                })
7796                .cloned()
7797                .collect();
7798            v.sort();
7799            v
7800        };
7801        // Existing + absent terms in every position (and unbound) — 4×4×4 shapes.
7802        let sv = [
7803            None,
7804            Some("<http://ex/s/0007>"),
7805            Some("<http://ex/s/0130>"),
7806            Some("<http://ex/s/9999>"),
7807        ];
7808        let pv = [
7809            None,
7810            Some("<http://ex/p/3>"),
7811            Some("<http://ex/p/6>"),
7812            Some("<http://ex/p/999>"),
7813        ];
7814        let ov = [
7815            None,
7816            Some("<http://ex/o/0000>"),
7817            Some("<http://ex/o/0012>"),
7818            Some("<http://ex/o/9999>"),
7819        ];
7820        for &s in &sv {
7821            for &p in &pv {
7822                for &o in &ov {
7823                    let mut e = eager.query(s, p, o);
7824                    e.sort();
7825                    let mut l = lazy.query(s, p, o);
7826                    l.sort();
7827                    let r = brute(s, p, o);
7828                    assert_eq!(e, r, "eager {s:?} {p:?} {o:?}");
7829                    assert_eq!(l, r, "lazy {s:?} {p:?} {o:?} — synopsis over-pruned");
7830                }
7831            }
7832        }
7833        assert!(!lazy.index_incomplete(), "no lazy fetch failed");
7834    }
7835
7836    /// Byte-0 / polyglot experiment: a real `.rete` embedded behind a large HTML
7837    /// shell (so byte 0 is `<`, not `RETE`) still opens and queries LAZILY through
7838    /// an `OffsetReader`, touching only the graph's bytes and never the shell.
7839    #[test]
7840    fn polyglot_offset_reads_lazily() {
7841        use crate::reader::{
7842            detect_polyglot_base, CountingReader, OffsetReader, SliceReader, POLYGLOT_DIGITS,
7843            POLYGLOT_MARKER,
7844        };
7845
7846        // A real .rete image, hidden behind a 50 KB HTML shell that carries the
7847        // polyglot base-offset marker in its first bytes (a browser-ignored
7848        // comment). The .rete begins right after the shell.
7849        let image = build_image();
7850        let mut shell = Vec::new();
7851        shell.extend_from_slice(b"<!DOCTYPE html><html><head><!--");
7852        shell.extend_from_slice(POLYGLOT_MARKER);
7853        let digits_at = shell.len();
7854        shell.extend_from_slice(&[b'0'; POLYGLOT_DIGITS]); // patched below
7855        shell.extend_from_slice(b"--></head><body>a web page</body></html>\n");
7856        shell.resize(50_000, b' '); // >> the .rete, to prove the shell is untouched
7857        let base = shell.len() as u64;
7858        let digits = format!("{base:0width$}", width = POLYGLOT_DIGITS);
7859        shell[digits_at..digits_at + POLYGLOT_DIGITS].copy_from_slice(digits.as_bytes());
7860
7861        let mut poly = shell;
7862        poly.extend_from_slice(&image);
7863
7864        // byte 0 is a web page, not a .rete...
7865        assert_ne!(
7866            &poly[0..4],
7867            b"RETE",
7868            "polyglot must not start with the magic"
7869        );
7870        // ...but the marker in the first header window points at the embedded .rete.
7871        let detected = detect_polyglot_base(&poly[..crate::header::HEADER_LEN]).unwrap();
7872        assert_eq!(detected, base);
7873
7874        // Open it LAZILY through the offset shim and query it.
7875        let leaked: &'static [u8] = Box::leak(poly.into_boxed_slice());
7876        let counting = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
7877        let lazy = Rete::open_ranged_lazy_with(
7878            OffsetReader::new(counting.clone(), detected),
7879            HEADER_LEN as u64,
7880        )
7881        .unwrap();
7882
7883        // The polyglot must answer exactly like the plain .rete it embeds.
7884        let plain = Rete::open(&image).unwrap();
7885        let mut want = plain.query(None, None, None);
7886        want.sort();
7887        let mut got = lazy.query(None, None, None);
7888        got.sort();
7889        assert_eq!(
7890            got, want,
7891            "polyglot lazy query differs from the plain .rete"
7892        );
7893        assert!(!got.is_empty(), "expected some triples");
7894        assert!(!lazy.index_incomplete(), "a lazy fetch failed");
7895
7896        // It was LAZY: it read fewer bytes than the HTML shell alone, i.e. it
7897        // never touched the prefix — only the embedded .rete's own bytes.
7898        let read = counting.bytes_read();
7899        eprintln!(
7900            "polyglot lazy read: HTML shell {base} B + .rete image {} B; \
7901             the query touched only {read} B (never the shell).",
7902            image.len()
7903        );
7904        assert!(
7905            read < base,
7906            "read {read} bytes; the HTML shell alone is {base} — the reader \
7907             touched the prefix instead of only the embedded .rete"
7908        );
7909    }
7910
7911    /// Build a small file whose objects are string literals, **with** a text
7912    /// index, and return `(image, triples)`. Shared by the text-index tests.
7913    #[cfg(test)]
7914    fn build_text_indexed(triples: &[(String, String, String)]) -> Vec<u8> {
7915        let mut db = DictionaryBuilder::new();
7916        for (s, p, o) in triples {
7917            db.observe(s, p, o);
7918        }
7919        let dict = db.build();
7920        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
7921        let mut id_triples: Vec<(u32, u32, u32)> = Vec::with_capacity(triples.len());
7922        for (s, p, o) in triples {
7923            let t = dict.encode(s, p, o).unwrap();
7924            ib.push(t);
7925            id_triples.push(t);
7926        }
7927        let index = ib.build();
7928        let text_index = compute_text_index(&dict, &id_triples);
7929        assert!(
7930            !text_index.is_empty(),
7931            "literals should produce a text index"
7932        );
7933        write_dataset_with_metadata(&dict, &index, &[], false, &[], 0, &[], &text_index)
7934    }
7935
7936    /// A `--text-index` build round-trips: `text_search` returns exactly the
7937    /// subjects whose literals contain the queried word(s), with AND across words
7938    /// and token-prefix — matching a brute-force scan of the literals.
7939    #[test]
7940    fn text_index_eager_matches_brute_force() {
7941        let triples: Vec<(String, String, String)> = vec![
7942            (
7943                "<http://ex/s0>",
7944                "<http://ex/label>",
7945                "\"alpha glucose phosphate\"",
7946            ),
7947            ("<http://ex/s1>", "<http://ex/label>", "\"beta Glucose\""),
7948            ("<http://ex/s2>", "<http://ex/label>", "\"gamma fructose\""),
7949            (
7950                "<http://ex/s3>",
7951                "<http://ex/note>",
7952                "\"einstein relativity\"",
7953            ),
7954            (
7955                "<http://ex/s4>",
7956                "<http://ex/ref>",
7957                "<http://ex/not-a-literal>",
7958            ),
7959        ]
7960        .into_iter()
7961        .map(|(s, p, o)| (s.to_string(), p.to_string(), o.to_string()))
7962        .collect();
7963        let bytes = build_text_indexed(&triples);
7964        let rete = Rete::open(&bytes).unwrap();
7965        assert!(rete.has_text_index());
7966
7967        // Brute-force reference: subjects whose literal objects contain all words.
7968        let brute = |words: &[&str]| -> Vec<String> {
7969            let mut v: Vec<String> = triples
7970                .iter()
7971                .filter(|(_, _, o)| {
7972                    crate::terms::is_literal(o)
7973                        && words.iter().all(|w| {
7974                            let wl = w.to_lowercase();
7975                            crate::terms::literal_lexical(o)
7976                                .unwrap()
7977                                .split(|c: char| !c.is_alphanumeric())
7978                                .any(|t| t.to_lowercase() == wl)
7979                        })
7980                })
7981                .map(|(s, _, _)| s.clone())
7982                .collect();
7983            v.sort();
7984            v.dedup();
7985            v
7986        };
7987
7988        let mut got = rete.text_search(&["glucose"], None, 0);
7989        got.sort();
7990        assert_eq!(got, brute(&["glucose"]), "case-insensitive single word");
7991
7992        // AND across two words: only s0 has both.
7993        let mut got = rete.text_search(&["glucose", "phosphate"], None, 0);
7994        got.sort();
7995        assert_eq!(got, brute(&["glucose", "phosphate"]));
7996
7997        // A word nobody has → empty.
7998        assert!(rete.text_search(&["zzznope"], None, 0).is_empty());
7999
8000        // Token-prefix: "ein…" matches "einstein".
8001        let got = rete.text_search(&[], Some("ein"), 0);
8002        assert_eq!(got, vec!["<http://ex/s3>".to_string()]);
8003
8004        // No text index → empty, has_text_index() false.
8005        let mut db = DictionaryBuilder::new();
8006        for (s, p, o) in &triples {
8007            db.observe(s, p, o);
8008        }
8009        let dict = db.build();
8010        let mut ib = GraphIndexBuilder::new();
8011        for (s, p, o) in &triples {
8012            ib.push(dict.encode(s, p, o).unwrap());
8013        }
8014        let plain = write_dataset(&dict, &ib.build(), &[], false, &[], 0);
8015        let plain_rete = Rete::open(&plain).unwrap();
8016        assert!(!plain_rete.has_text_index());
8017        assert!(plain_rete.text_search(&["glucose"], None, 0).is_empty());
8018    }
8019
8020    /// The lazy/remote path returns the same subjects as the eager path **and**
8021    /// faults only the token table + the queried posting list — never the whole
8022    /// postings blob. A `CountingReader` proves the byte budget stays small.
8023    #[test]
8024    fn text_index_lazy_faults_only_queried_postings() {
8025        use crate::reader::{CountingReader, SliceReader};
8026        // Many subjects so the postings blob is large relative to one posting:
8027        // every subject carries "common", but only a few carry "rare".
8028        let mut triples: Vec<(String, String, String)> = (0..300u32)
8029            .map(|i| {
8030                (
8031                    format!("<http://ex/s/{i:04}>"),
8032                    "<http://ex/label>".to_string(),
8033                    format!("\"common word number {i}\""),
8034                )
8035            })
8036            .collect();
8037        for i in [3u32, 77, 250] {
8038            triples.push((
8039                format!("<http://ex/s/{i:04}>"),
8040                "<http://ex/tag>".to_string(),
8041                "\"raretoken\"".to_string(),
8042            ));
8043        }
8044        let bytes = build_text_indexed(&triples);
8045        let eager = Rete::open(&bytes).unwrap();
8046        let mut want = eager.text_search(&["raretoken"], None, 0);
8047        want.sort();
8048        assert_eq!(want.len(), 3);
8049
8050        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
8051        let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
8052        let lazy = Rete::open_ranged_lazy_with(reader.clone(), HEADER_LEN as u64).unwrap();
8053        // Bytes pulled by the open itself (dict dirs, index dirs, named graphs) —
8054        // the text index is deferred and not touched yet.
8055        let before = reader.bytes_read();
8056        let mut got = lazy.text_search(&["raretoken"], None, 0);
8057        got.sort();
8058        assert_eq!(got, want, "lazy search matches eager");
8059        let pulled = reader.bytes_read() - before;
8060        // The search faulted the token table + the one "raretoken" posting; it must
8061        // be far less than the whole text-index section (300 "common" postings).
8062        let ti_len = eager.header().text_index_len;
8063        assert!(
8064            pulled < ti_len,
8065            "search pulled {pulled} B but the section is {ti_len} B — faulted too much"
8066        );
8067        assert!(!lazy.index_incomplete());
8068    }
8069
8070    /// `SearchView` answers exactly what a full open answers, having read
8071    /// strictly less: it skips the permutation tile directories and — the part
8072    /// that actually costs on a literal-heavy graph — the object-only
8073    /// dictionary's chunk directory.
8074    #[test]
8075    fn search_view_matches_full_open_for_far_fewer_bytes() {
8076        use crate::reader::{CountingReader, SliceReader};
8077        // Long object literals so the object-only dictionary (and its chunk
8078        // directory) dominates the file, as on a graph that stores abstracts.
8079        let filler = "lorem ipsum dolor sit amet consectetur adipiscing elit ".repeat(20);
8080        let mut triples: Vec<(String, String, String)> = (0..300u32)
8081            .map(|i| {
8082                (
8083                    format!("<http://ex/s/{i:04}>"),
8084                    "<http://ex/abstract>".to_string(),
8085                    format!("\"{filler} number {i}\""),
8086                )
8087            })
8088            .collect();
8089        for i in [3u32, 77, 250] {
8090            triples.push((
8091                format!("<http://ex/s/{i:04}>"),
8092                "<http://ex/tag>".to_string(),
8093                "\"raretoken\"".to_string(),
8094            ));
8095        }
8096        let bytes = build_text_indexed(&triples);
8097        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
8098
8099        let full_reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
8100        let full = Rete::open_ranged_lazy_with(full_reader.clone(), HEADER_LEN as u64).unwrap();
8101        let mut want = full.text_search(&["raretoken"], None, 0);
8102        want.sort();
8103        assert_eq!(want.len(), 3);
8104
8105        let search_reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
8106        let view = SearchView::open_ranged_with(search_reader.clone(), HEADER_LEN as u64).unwrap();
8107        assert!(view.has_text_index());
8108        let mut got = view.text_search(&["raretoken"], None, 0);
8109        got.sort();
8110        assert_eq!(got, want, "search view matches the full open");
8111        assert!(
8112            search_reader.bytes_read() < full_reader.bytes_read(),
8113            "search read {} B, full open read {} B — the narrow open bought nothing",
8114            search_reader.bytes_read(),
8115            full_reader.bytes_read()
8116        );
8117
8118        // A file without a text index says so from the header alone, and the
8119        // label-prefix mode still works through the same view.
8120        let mut prefix_hits = view.prefix_search("", 5);
8121        prefix_hits.sort();
8122        assert!(!prefix_hits.is_empty() || !view.has_pyramid());
8123    }
8124
8125    /// Nothing records a dictionary chunk directory's byte length, so a ranged
8126    /// open probes for it. That probe must cost about ONE directory, not several
8127    /// — it used to re-read the whole prefix on every round, so the sum was
8128    /// ~2x the last (already over-sized) read: 537 MB of range reads to fetch
8129    /// epfl-infoscience's 234 MB object-only directory. Here the same probe runs
8130    /// against a file whose object literals are long enough that its directory
8131    /// is well past the 8 KiB header prefix, and must (a) stay inside the
8132    /// append-only bound of 2x the directory and (b) beat what the old
8133    /// re-reading loop would have spent.
8134    #[test]
8135    fn dict_chunk_directory_probe_costs_about_one_directory() {
8136        use crate::reader::{CountingReader, SliceReader};
8137        // Long literals that share a 3 KiB prefix with their neighbour, each
8138        // ending in a 1 KiB tail of its own: the WKT-polygon / JSON-blob shape
8139        // (#198's weak case). The directory stores the SHORTEST SEPARATOR per
8140        // chunk, and a separator has to reproduce the whole shared prefix — so
8141        // these keys are still ~3 KiB and the directory still runs well past
8142        // the 8 KiB header prefix, which is what gives the probe something to
8143        // measure.
8144        //
8145        // This fixture used to put the varying bytes FIRST (`"{i:04} xxxx…"`).
8146        // That shape is the one separators annihilate: its keys collapse to ~4
8147        // bytes, the whole directory to 192 B, it fits the header prefix, and
8148        // the probe never runs — the assertion below then fails naming itself.
8149        // Keep the shared prefix.
8150        let head = "x".repeat(3000);
8151        let triples: Vec<(String, String, String)> = (0..2000u32)
8152            .map(|i| {
8153                let tail = char::from(b'a' + (i % 26) as u8).to_string().repeat(1000);
8154                (
8155                    format!("<http://ex/s/{i:04}>"),
8156                    "<http://ex/abstract>".to_string(),
8157                    format!("\"{head}{i:04}{tail}\""),
8158                )
8159            })
8160            .collect();
8161        let mut db = DictionaryBuilder::new();
8162        for (s, p, o) in &triples {
8163            db.observe(s, p, o);
8164        }
8165        let dict = db.build();
8166        let mut ib = GraphIndexBuilder::new();
8167        for (s, p, o) in &triples {
8168            ib.push(dict.encode(s, p, o).unwrap());
8169        }
8170        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
8171
8172        let reader = CountingReader::new(SliceReader::new(&bytes));
8173        let header = Header::from_bytes(&reader.read_at(0, HEADER_LEN as u64).unwrap()).unwrap();
8174        // Section 2 is object-only: the one carrying the long literals.
8175        let section = locate_container_section_ranged(
8176            &reader,
8177            header.dictionary_offset,
8178            header.dictionary_len,
8179            2,
8180            4,
8181        )
8182        .unwrap();
8183
8184        let before = reader.bytes_read();
8185        let (_meta, entries) = read_dict_dir_ranged(&reader, section).unwrap();
8186        let spent = reader.bytes_read() - before;
8187
8188        // The first chunk body starts where the directory ends.
8189        let dir_end = entries[0].start;
8190        let head = reader.read_at(section.offset, 64).unwrap();
8191        let (header_len, n0) = read_uvarint(&head).unwrap();
8192        let dir_start = n0 as u64 + header_len;
8193        let dir_len = dir_end - dir_start;
8194        let dir_total = section.len - dir_start;
8195        assert!(
8196            dir_start + dir_len > 8192,
8197            "directory fits the header prefix ({dir_len} B) — the probe never runs"
8198        );
8199
8200        // What the previous loop spent: every round re-read from the start.
8201        let mut old = 0u64;
8202        let mut p = 4096u64.min(dir_total).max(1);
8203        loop {
8204            let got = p.min(dir_total);
8205            old += got;
8206            if got >= dir_len || p >= dir_total {
8207                break;
8208            }
8209            p = p.saturating_mul(2).min(dir_total);
8210        }
8211        assert!(
8212            spent <= dir_len * 2,
8213            "probe read {spent} B for a {dir_len} B directory — past the append-only bound"
8214        );
8215        assert!(
8216            spent < old,
8217            "probe read {spent} B; the re-reading loop it replaced spent {old} B"
8218        );
8219    }
8220
8221    // ---------------------------------------------------------------------
8222    // Separator routing keys (#198 option (d))
8223    // ---------------------------------------------------------------------
8224
8225    /// A verbatim-keyed twin of a chunked dictionary section: same header, same
8226    /// chunking, byte-identical compressed bodies — only every directory key
8227    /// replaced by the chunk's actual first term. That is exactly what
8228    /// `encode_chunked_dict_section` wrote before separator keys, so it is the
8229    /// control for every comparison below (and for the proptest).
8230    fn verbatim_keyed_twin(payload: &[u8], codec: u8) -> Vec<u8> {
8231        let (_meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64).unwrap();
8232        let (header_len, n0) = read_uvarint(payload).unwrap();
8233        let mut out = Vec::new();
8234        write_uvarint(&mut out, header_len);
8235        out.extend_from_slice(&payload[n0..n0 + header_len as usize]);
8236        write_uvarint(&mut out, entries.len() as u64);
8237        let mut prev_run = 0usize;
8238        for e in &entries {
8239            let body = decompress(codec, &payload[e.start as usize..e.end as usize]).unwrap();
8240            let first = crate::dict::run_first_term(&body, 0).unwrap();
8241            write_uvarint(&mut out, (e.first_run - prev_run) as u64);
8242            write_uvarint(&mut out, first.len() as u64);
8243            out.extend_from_slice(&first);
8244            write_uvarint(&mut out, e.end - e.start);
8245            prev_run = e.first_run;
8246        }
8247        for e in &entries {
8248            out.extend_from_slice(&payload[e.start as usize..e.end as usize]);
8249        }
8250        out
8251    }
8252
8253    /// Each chunk's (first term, last term), decoded from the bodies.
8254    fn chunk_bounds_of(payload: &[u8], codec: u8) -> Vec<(Vec<u8>, Vec<u8>)> {
8255        let (_meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64).unwrap();
8256        entries
8257            .iter()
8258            .map(|e| {
8259                let body = decompress(codec, &payload[e.start as usize..e.end as usize]).unwrap();
8260                (
8261                    crate::dict::run_first_term(&body, 0).unwrap(),
8262                    crate::dict::run_last_term(&body, 0, body.len()).unwrap(),
8263                )
8264            })
8265            .collect()
8266    }
8267
8268    /// A term set built to hit every shape the corpus has: plain IRIs, literals
8269    /// sharing a long prefix with their neighbour (separators are LONG there —
8270    /// the WKT/`ohm-full` weak case), literals that diverge in their first bytes
8271    /// (separators are tiny — the `epfl-infoscience` case), literals larger than
8272    /// `DICT_CHUNK_BUDGET` on their own, and multibyte UTF-8.
8273    fn adversarial_terms() -> Vec<String> {
8274        let mut terms: Vec<String> = Vec::new();
8275        for i in 0..6_000 {
8276            terms.push(format!("<http://example.org/work/{i:06}>"));
8277        }
8278        let shared = "X".repeat(900);
8279        for i in 0..4_000 {
8280            terms.push(format!("\"{shared}-{i:05}-tail\""));
8281        }
8282        let deep = "Y".repeat(3_000); // shared prefix far past any short key
8283        for i in 0..120 {
8284            terms.push(format!("\"{deep}{i:03}\""));
8285        }
8286        for i in 0..6 {
8287            // one literal larger than the 64 KiB budget: a chunk of one run
8288            terms.push(format!("\"BIG-{i:02}-{}\"", "Z".repeat(90_000)));
8289        }
8290        for i in 0..800 {
8291            terms.push(format!(
8292                "\"h\u{e9}llo-{}-\u{1F600}{i:04}\"",
8293                "\u{e9}".repeat(40)
8294            ));
8295        }
8296        for i in 0..2_000 {
8297            terms.push(format!("\"{i:05}-{}\"", "T".repeat(1_800)));
8298        }
8299        terms.sort();
8300        terms.dedup();
8301        terms
8302    }
8303
8304    fn chunked_section_of(terms: &[String]) -> Vec<u8> {
8305        let mut b = crate::dict::DictSectionBuilder::new().with_restart_interval(16);
8306        for t in terms {
8307            b.push(t.clone());
8308        }
8309        b.build()
8310    }
8311
8312    /// The invariant, asserted against the real chunk contents: the stored key
8313    /// of chunk `i` is strictly above chunk `i-1`'s LAST term and at most chunk
8314    /// `i`'s own first term, chunk 0's key is empty, and the keys ascend — the
8315    /// three facts `ChunkedSection::id`'s `partition_point` relies on.
8316    ///
8317    /// Plus the anti-regression the rename exists for: on a section like this
8318    /// the key must NOT be the first term. A writer that quietly went back to
8319    /// storing terms would pass every other test in this file — dumps and
8320    /// `term(id)` route by `first_run` and never read the key.
8321    #[test]
8322    fn chunk_directory_keys_are_separators_not_terms() {
8323        let terms = adversarial_terms();
8324        let raw = chunked_section_of(&terms);
8325        let payload = encode_chunked_dict_section(&raw, CODEC_NONE);
8326        let (_meta, entries) = parse_chunked_dict_dir(&payload, payload.len() as u64).unwrap();
8327        let bounds = chunk_bounds_of(&payload, CODEC_NONE);
8328        assert!(
8329            entries.len() > 20,
8330            "need a many-chunk section, got {}",
8331            entries.len()
8332        );
8333
8334        assert!(entries[0].key.is_empty(), "chunk 0 needs no separator");
8335        let mut key_bytes = 0usize;
8336        let mut term_bytes = 0usize;
8337        let mut differ = 0usize;
8338        for (i, e) in entries.iter().enumerate() {
8339            key_bytes += e.key.len();
8340            term_bytes += bounds[i].0.len();
8341            if e.key != bounds[i].0 {
8342                differ += 1;
8343            }
8344            assert!(
8345                e.key <= bounds[i].0,
8346                "chunk {i}: key must be <= its own first term"
8347            );
8348            if i > 0 {
8349                assert!(
8350                    e.key.as_slice() > bounds[i - 1].1.as_slice(),
8351                    "chunk {i}: key must be > the previous chunk's last term"
8352                );
8353                assert!(
8354                    e.key > entries[i - 1].key,
8355                    "chunk {i}: keys must ascend for partition_point"
8356                );
8357                // shortest: one byte past the divergence from the predecessor
8358                assert_eq!(
8359                    e.key,
8360                    crate::dict::shortest_separator(&bounds[i - 1].1, &bounds[i].0),
8361                    "chunk {i}: key is not the SHORTEST separator"
8362                );
8363            }
8364        }
8365        assert!(
8366            differ > 0,
8367            "every key equalled its first term — the writer stores terms again"
8368        );
8369        assert!(
8370            key_bytes * 4 < term_bytes,
8371            "separators bought almost nothing: {key_bytes} B vs {term_bytes} B of first terms"
8372        );
8373        eprintln!(
8374            "chunks={} first-term keys={term_bytes} B  separator keys={key_bytes} B ({:.1}x)",
8375            entries.len(),
8376            term_bytes as f64 / key_bytes.max(1) as f64
8377        );
8378    }
8379
8380    /// A separator-keyed section answers **identically** to the verbatim-keyed
8381    /// one built over the same bodies, under the same unmodified reader: every
8382    /// id, every term, and every boundary probe — the first and last term of
8383    /// each chunk, one byte either side of each, strings that fall strictly
8384    /// *between* two chunks, truncations, the empty string, and probes below
8385    /// and above the whole section.
8386    ///
8387    /// Probing `id(term)` is the entire point. A broken key (a truncation, a
8388    /// hash) leaves `term(id)`, `dump` and `export` byte-perfect — they route by
8389    /// `first_run` — and only lookups lie.
8390    #[test]
8391    fn separator_keys_route_identically_to_verbatim_first_terms() {
8392        let terms = adversarial_terms();
8393        let raw = chunked_section_of(&terms);
8394        let sep_payload = encode_chunked_dict_section(&raw, CODEC_NONE);
8395        let ver_payload = verbatim_keyed_twin(&sep_payload, CODEC_NONE);
8396
8397        // Same chunking, byte-identical bodies — only the keys differ.
8398        let (_ms, es) = parse_chunked_dict_dir(&sep_payload, sep_payload.len() as u64).unwrap();
8399        let (_mv, ev) = parse_chunked_dict_dir(&ver_payload, ver_payload.len() as u64).unwrap();
8400        assert_eq!(es.len(), ev.len());
8401        for (a, b) in es.iter().zip(ev.iter()) {
8402            assert_eq!(a.first_run, b.first_run);
8403            assert_eq!(
8404                &sep_payload[a.start as usize..a.end as usize],
8405                &ver_payload[b.start as usize..b.end as usize],
8406                "chunk bodies must be identical"
8407            );
8408        }
8409        assert!(
8410            sep_payload.len() < ver_payload.len(),
8411            "separator payload is not smaller"
8412        );
8413
8414        let sec_s = decode_chunked_dict_section(
8415            &sep_payload,
8416            CODEC_NONE,
8417            crate::chunk_cache::ChunkCache::unlimited_arc(),
8418            0,
8419        )
8420        .unwrap();
8421        let sec_v = decode_chunked_dict_section(
8422            &ver_payload,
8423            CODEC_NONE,
8424            crate::chunk_cache::ChunkCache::unlimited_arc(),
8425            0,
8426        )
8427        .unwrap();
8428
8429        // 1. every present term → the same, correct id
8430        for (i, t) in terms.iter().enumerate() {
8431            let want = Some(i as u32 + 1);
8432            assert_eq!(sec_v.id(t), want, "verbatim id() lost a term");
8433            assert_eq!(sec_s.id(t), want, "separator id() lost a term");
8434        }
8435        // 2. every id → the same term
8436        for i in 0..terms.len() as u32 {
8437            assert_eq!(sec_s.term(i + 1), sec_v.term(i + 1));
8438            assert_eq!(
8439                sec_s.term(i + 1).as_deref(),
8440                Some(terms[i as usize].as_str())
8441            );
8442        }
8443        // 3. boundary probes, from the real chunk bounds
8444        let bounds = chunk_bounds_of(&sep_payload, CODEC_NONE);
8445        let mut probes: Vec<String> = Vec::new();
8446        let push = |bytes: &[u8], probes: &mut Vec<String>| {
8447            if let Ok(s) = std::str::from_utf8(bytes) {
8448                probes.push(s.to_string());
8449            }
8450        };
8451        for (first, last) in &bounds {
8452            for base in [first, last] {
8453                push(base, &mut probes); // present
8454                let mut after = base.clone();
8455                after.push(0x01); // immediately after — between chunks when base = last
8456                push(&after, &mut probes);
8457                let mut far = base.clone();
8458                far.push(b'~');
8459                push(&far, &mut probes);
8460                if !base.is_empty() {
8461                    push(&base[..base.len() - 1], &mut probes); // truncated
8462                    let mut down = base.clone();
8463                    *down.last_mut().unwrap() = down.last().unwrap().wrapping_sub(1);
8464                    push(&down, &mut probes);
8465                    let mut up = base.clone();
8466                    *up.last_mut().unwrap() = up.last().unwrap().wrapping_add(1);
8467                    push(&up, &mut probes);
8468                }
8469            }
8470        }
8471        // 4. the stored separators themselves, and the ends of the world
8472        for e in &es {
8473            push(&e.key, &mut probes);
8474            let mut plus = e.key.clone();
8475            plus.push(0);
8476            push(&plus, &mut probes);
8477        }
8478        probes.push(String::new()); // the empty key, i.e. chunk 0's
8479        probes.push("\u{1}".to_string()); // below every term
8480        probes.push("\u{10FFFF}\u{10FFFF}".to_string()); // above every term
8481        probes.push(terms.first().unwrap().clone());
8482        probes.push(terms.last().unwrap().clone());
8483
8484        let mut hits = 0usize;
8485        for p in &probes {
8486            let (a, b) = (sec_v.id(p), sec_s.id(p));
8487            assert_eq!(
8488                a,
8489                b,
8490                "probe diverged: {:?}",
8491                p.get(..p.len().min(48)).unwrap_or(p.as_str())
8492            );
8493            hits += usize::from(a.is_some());
8494        }
8495        assert!(
8496            probes.len() > 500 && hits > 50,
8497            "probe set is too thin: {} probes, {hits} of them present",
8498            probes.len()
8499        );
8500        eprintln!(
8501            "{} boundary probes, {hits} present, all identical",
8502            probes.len()
8503        );
8504    }
8505
8506    /// The same claim as a **property**: for arbitrary term sets and arbitrary
8507    /// probes, a separator-keyed section and the verbatim-keyed twin built from
8508    /// the same input are indistinguishable through the reader. The fixed test
8509    /// above picks the shapes I thought of; this one shrinks a counterexample
8510    /// out of the ones I did not.
8511    mod separator_props {
8512        use super::*;
8513        use proptest::prelude::*;
8514
8515        /// Sorted, deduplicated term sets mixing the three shapes that decide a
8516        /// separator's length: short IRIs, literals whose payload comes BEFORE
8517        /// the discriminating bytes (long shared prefixes ⇒ long separators),
8518        /// and literals that diverge immediately (⇒ one-byte separators). Sizes
8519        /// straddle `DICT_CHUNK_BUDGET`, so single- and many-chunk sections both
8520        /// occur.
8521        fn term_set() -> impl Strategy<Value = Vec<String>> {
8522            prop::collection::vec(("[a-c]{1,6}", 0u8..3, 300usize..1200), 60..250).prop_map(
8523                |specs| {
8524                    let mut terms: Vec<String> = specs
8525                        .into_iter()
8526                        .enumerate()
8527                        .map(|(i, (core, kind, pad))| {
8528                            let block = "z".repeat(pad);
8529                            match kind {
8530                                0 => format!("<http://ex/{core}/{i:05}>"),
8531                                1 => format!("\"{block}{core}{i:05}\""),
8532                                _ => format!("\"{core}{i:05}{block}\""),
8533                            }
8534                        })
8535                        .collect();
8536                    terms.sort();
8537                    terms.dedup();
8538                    terms
8539                },
8540            )
8541        }
8542
8543        proptest! {
8544            #[test]
8545            fn prop_separator_keyed_section_answers_like_verbatim(
8546                terms in term_set(),
8547                extra in prop::collection::vec(any::<String>(), 0..8),
8548            ) {
8549                let raw = chunked_section_of(&terms);
8550                let sep = encode_chunked_dict_section(&raw, CODEC_NONE);
8551                let ver = verbatim_keyed_twin(&sep, CODEC_NONE);
8552                prop_assert!(sep.len() <= ver.len());
8553
8554                // The separator invariant, against the real chunk contents.
8555                let (_m, es) = parse_chunked_dict_dir(&sep, sep.len() as u64).unwrap();
8556                let bounds = chunk_bounds_of(&sep, CODEC_NONE);
8557                prop_assert!(es[0].key.is_empty());
8558                for i in 1..es.len() {
8559                    prop_assert!(es[i].key.as_slice() > bounds[i - 1].1.as_slice());
8560                    prop_assert!(es[i].key <= bounds[i].0);
8561                    prop_assert!(es[i].key > es[i - 1].key);
8562                }
8563
8564                let sec_s = decode_chunked_dict_section(
8565                    &sep,
8566                    CODEC_NONE,
8567                    crate::chunk_cache::ChunkCache::unlimited_arc(),
8568                    0,
8569                )
8570                .unwrap();
8571                let sec_v = decode_chunked_dict_section(
8572                    &ver,
8573                    CODEC_NONE,
8574                    crate::chunk_cache::ChunkCache::unlimited_arc(),
8575                    0,
8576                )
8577                .unwrap();
8578
8579                for (i, t) in terms.iter().enumerate() {
8580                    prop_assert_eq!(sec_s.id(t), Some(i as u32 + 1));
8581                    prop_assert_eq!(sec_v.id(t), Some(i as u32 + 1));
8582                    prop_assert_eq!(sec_s.term(i as u32 + 1), sec_v.term(i as u32 + 1));
8583                }
8584
8585                // Probes: arbitrary strings, plus mutations of every boundary.
8586                let mut probes: Vec<String> = extra;
8587                probes.push(String::new());
8588                for (first, last) in &bounds {
8589                    for base in [first, last] {
8590                        if let Ok(s) = std::str::from_utf8(base) {
8591                            probes.push(s.to_string());
8592                            probes.push(format!("{s}\u{1}"));
8593                            probes.push(s[..s.len() - s.chars().next_back()
8594                                .map(char::len_utf8).unwrap_or(0)].to_string());
8595                        }
8596                    }
8597                }
8598                for e in &es {
8599                    if let Ok(s) = std::str::from_utf8(&e.key) {
8600                        probes.push(s.to_string());
8601                    }
8602                }
8603                for p in &probes {
8604                    prop_assert_eq!(sec_s.id(p), sec_v.id(p), "probe diverged: {:?}", p);
8605                }
8606            }
8607        }
8608    }
8609
8610    /// The TEXT_INDEX section is inside the content hash: a freshly built
8611    /// text-indexed file must pass `verify()`, and flipping a byte inside the
8612    /// section must break it. (Regression: `verify()` once rebuilt the hash
8613    /// without the text index, so every `--text-index` file failed as corrupt.)
8614    #[test]
8615    fn text_index_is_tamper_evident_and_verifies() {
8616        let triples: Vec<(String, String, String)> = vec![(
8617            "<http://ex/s0>".to_string(),
8618            "<http://ex/label>".to_string(),
8619            "\"alpha glucose phosphate\"".to_string(),
8620        )];
8621        let bytes = build_text_indexed(&triples);
8622        let header = Rete::open(&bytes).unwrap().header().clone();
8623        assert!(header.text_index_len > 0);
8624        assert!(verify(&bytes).unwrap(), "a text-indexed build must verify");
8625
8626        let mut tampered = bytes.clone();
8627        tampered[header.text_index_offset as usize] ^= 0xff;
8628        assert!(
8629            !verify(&tampered).unwrap(),
8630            "tampering with the text index must break verify()"
8631        );
8632    }
8633
8634    /// End-to-end win: on a remote (range-read) file, a lookup whose routed tile
8635    /// is ruled out by a bound secondary fetches **fewer bytes** with the synopsis
8636    /// than without it — and the answer is identical (empty) either way.
8637    #[test]
8638    fn synopsis_cuts_remote_fetch_bytes() {
8639        use crate::header::FLAG_TILE_SYNOPSIS;
8640        use crate::reader::{CountingReader, SliceReader};
8641
8642        // Zero-padded terms ⇒ dictionary ids are monotonic in i; subject s_i pairs
8643        // only with object o_i, so an OSP tile (routed by object) holds a
8644        // contiguous subject range — a subject from a far tile is provably absent.
8645        let triples: Vec<(String, String, String)> = (0..400u32)
8646            .map(|i| {
8647                (
8648                    format!("<http://ex/s/{i:04}>"),
8649                    "<http://ex/p>".to_string(),
8650                    format!("<http://ex/o/{i:04}>"),
8651                )
8652            })
8653            .collect();
8654        let mut db = DictionaryBuilder::new();
8655        for (s, p, o) in &triples {
8656            db.observe(s, p, o);
8657        }
8658        let dict = db.build();
8659        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
8660        for (s, p, o) in &triples {
8661            ib.push(dict.encode(s, p, o).unwrap());
8662        }
8663        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
8664
8665        // (s_0395, ?, o_0005): routes OSP by the (early) object, secondary = the
8666        // (late) subject — outside that tile's subject range, so the synopsis
8667        // prunes the one routed tile.
8668        let q = (Some("<http://ex/s/0395>"), None, Some("<http://ex/o/0005>"));
8669        // Measure the bytes the QUERY pulls (after open) — isolating the per-query
8670        // saving from the one-time synopsis trailer reads done at open, which a
8671        // persistent remote session amortizes over many queries.
8672        let query_bytes = |image: &[u8]| -> (u64, usize) {
8673            let leaked: &'static [u8] = Box::leak(image.to_vec().into_boxed_slice());
8674            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
8675            let rete = Rete::open_ranged_lazy_with(reader.clone(), HEADER_LEN as u64).unwrap();
8676            let before = reader.bytes_read(); // after open (incl. trailer reads)
8677            let n = rete.query(q.0, q.1, q.2).len();
8678            assert!(!rete.index_incomplete());
8679            (reader.bytes_read() - before, n)
8680        };
8681
8682        let (on_bytes, on_n) = query_bytes(&bytes);
8683        // Same file with the synopsis flag cleared = an older reader's behavior.
8684        let mut off = bytes.clone();
8685        off[5] &= !FLAG_TILE_SYNOPSIS;
8686        let (off_bytes, off_n) = query_bytes(&off);
8687
8688        assert_eq!(on_n, 0, "the pair never co-occurs");
8689        assert_eq!(off_n, 0, "same answer without the synopsis");
8690        // Both pay the same dictionary-resolution bytes; the difference is the one
8691        // routed index tile that the synopsis skips (and the no-synopsis path
8692        // fetches only to have its zone map reject it).
8693        assert!(
8694            on_bytes < off_bytes,
8695            "synopsis skips the routed tile fetch: {on_bytes} < {off_bytes}"
8696        );
8697    }
8698
8699    /// A double-bound-object intersection (`?p P o1 ; P o2 ; label ?l`) — the
8700    /// shape whose REMOTE join strategy changed (scan + hash-join instead of
8701    /// probing each prefix row) — must return the SAME rows opened eagerly (in
8702    /// memory) and lazily (remote-style, `is_remote()` true). Strategy is a
8703    /// performance choice; the result multiset is invariant.
8704    #[test]
8705    fn double_bound_object_join_eager_matches_lazy() {
8706        use crate::reader::SliceReader;
8707        let occ = "<http://ex/occ>";
8708        let phys = "<http://ex/physicist>";
8709        let phil = "<http://ex/philosopher>";
8710        let label = "<http://www.w3.org/2000/01/rdf-schema#label>";
8711        // p00..p19 are physicists; p00..p09 are also philosophers (the answer).
8712        let mut triples: Vec<(String, String, String)> = Vec::new();
8713        for i in 0..20u32 {
8714            triples.push((format!("<http://ex/p/{i:02}>"), occ.into(), phys.into()));
8715            if i < 10 {
8716                triples.push((format!("<http://ex/p/{i:02}>"), occ.into(), phil.into()));
8717            }
8718            triples.push((
8719                format!("<http://ex/p/{i:02}>"),
8720                label.into(),
8721                format!("\"Name {i:02}\""),
8722            ));
8723        }
8724        let mut db = DictionaryBuilder::new();
8725        for (s, p, o) in &triples {
8726            db.observe(s, p, o);
8727        }
8728        let dict = db.build();
8729        let mut ib = GraphIndexBuilder::new().with_tile_budget(16);
8730        for (s, p, o) in &triples {
8731            ib.push(dict.encode(s, p, o).unwrap());
8732        }
8733        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
8734
8735        let q = "SELECT ?l WHERE { \
8736            ?p <http://ex/occ> <http://ex/physicist> ; \
8737               <http://ex/occ> <http://ex/philosopher> ; \
8738               <http://www.w3.org/2000/01/rdf-schema#label> ?l }";
8739        let run = |rete: &Rete| -> Vec<String> {
8740            let (_, sols) = crate::eval_sparql(rete, q).unwrap();
8741            let mut v: Vec<String> = sols.iter().filter_map(|b| b.get("l").cloned()).collect();
8742            v.sort();
8743            v
8744        };
8745
8746        let eager_rows = run(&Rete::open(&bytes).unwrap());
8747        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
8748        let lazy = Rete::open_ranged_lazy_with(
8749            std::sync::Arc::new(SliceReader::new(leaked)),
8750            HEADER_LEN as u64,
8751        )
8752        .unwrap();
8753        let lazy_rows = run(&lazy);
8754        assert!(!lazy.index_incomplete());
8755
8756        assert_eq!(eager_rows.len(), 10, "the 10 physicist∩philosopher labels");
8757        assert_eq!(eager_rows, lazy_rows, "eager and lazy must agree exactly");
8758    }
8759
8760    /// A dictionary big enough to split into multiple chunks per section must
8761    /// round-trip every id↔term mapping through the chunked (v0.2) encoding —
8762    /// including terms at chunk boundaries and absent near-misses.
8763    #[test]
8764    fn multi_chunk_dictionary_round_trips() {
8765        let mut db = DictionaryBuilder::new();
8766        let term = |i: u32| format!("<http://example.org/some/long/prefix/entity/{i:06}>");
8767        for i in 0..6000u32 {
8768            db.observe(&term(i), "<http://ex/p>", &term(i + 1));
8769        }
8770        let dict = db.build();
8771        let mut ib = GraphIndexBuilder::new();
8772        for i in 0..6000u32 {
8773            ib.push(
8774                dict.encode(&term(i), "<http://ex/p>", &term(i + 1))
8775                    .unwrap(),
8776            );
8777        }
8778        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
8779        let rete = Rete::open(&bytes).unwrap();
8780        let d = rete.dictionary();
8781        assert_eq!(d.term_count(), dict.term_count());
8782        for i in (0..6000).step_by(97).chain([0, 1, 5999, 6000]) {
8783            let t = term(i);
8784            let sid = dict.subject_id(&t);
8785            assert_eq!(d.subject_id(&t), sid, "subject_id({t})");
8786            if let Some(id) = sid {
8787                assert_eq!(d.subject_term(id).as_deref(), Some(t.as_str()));
8788            }
8789            let oid = dict.object_id(&t);
8790            assert_eq!(d.object_id(&t), oid, "object_id({t})");
8791        }
8792        assert_eq!(d.subject_id("<http://example.org/absent>"), None);
8793        assert_eq!(d.predicate_id("<http://ex/p>"), Some(1));
8794        assert_eq!(d.predicate_term(1).as_deref(), Some("<http://ex/p>"));
8795    }
8796
8797    #[test]
8798    #[cfg(feature = "compression")]
8799    fn compression_shrinks_repetitive_data() {
8800        // Many triples sharing IRI prefixes — exactly what front-coding + zstd
8801        // should crush. The compressed file must be far smaller than the raw
8802        // term bytes, and still query correctly.
8803        let mut db = DictionaryBuilder::new();
8804        let triples: Vec<(String, String, String)> = (0..500)
8805            .map(|i| {
8806                (
8807                    format!("<http://example.org/entity/{i}>"),
8808                    "<http://example.org/p/relatedTo>".to_string(),
8809                    format!("<http://example.org/entity/{}>", (i + 1) % 500),
8810                )
8811            })
8812            .collect();
8813        for (s, p, o) in &triples {
8814            db.observe(s, p, o);
8815        }
8816        let dict = db.build();
8817        let mut ib = GraphIndexBuilder::new();
8818        for (s, p, o) in &triples {
8819            ib.push(dict.encode(s, p, o).unwrap());
8820        }
8821        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
8822
8823        let raw: usize = triples
8824            .iter()
8825            .map(|(s, p, o)| s.len() + p.len() + o.len())
8826            .sum();
8827        assert!(
8828            bytes.len() < raw / 2,
8829            "expected strong compression: file {} vs raw terms {raw}",
8830            bytes.len()
8831        );
8832
8833        // Still queryable after compression.
8834        let rete = Rete::open(&bytes).unwrap();
8835        let r = rete.query(Some("<http://example.org/entity/0>"), None, None);
8836        assert_eq!(r.len(), 1);
8837        assert_eq!(r[0].2, "<http://example.org/entity/1>");
8838    }
8839
8840    fn big_file_with_pyramid() -> Vec<u8> {
8841        // A ring of 300 entities -> index dwarfs dict+meta.
8842        let triples: Vec<(String, String, String)> = (0..300)
8843            .map(|i| {
8844                (
8845                    format!("<http://ex/e{i}>"),
8846                    "<http://ex/next>".to_string(),
8847                    format!("<http://ex/e{}>", (i + 1) % 300),
8848                )
8849            })
8850            .collect();
8851        let mut db = DictionaryBuilder::new();
8852        for (s, p, o) in &triples {
8853            db.observe(s, p, o);
8854        }
8855        let dict = db.build();
8856        let ids: Vec<_> = triples
8857            .iter()
8858            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
8859            .collect();
8860        let mut ib = GraphIndexBuilder::new();
8861        for &t in &ids {
8862            ib.push(t);
8863        }
8864        let (meta, levels) = build_pyramid_meta(&dict, &ids, DEFAULT_TILE_BUDGET);
8865        write_file(&dict, &ib.build(), false, &meta, levels)
8866    }
8867
8868    #[test]
8869    fn ranged_open_is_minimal_and_correct() {
8870        use crate::reader::{CountingReader, SliceReader};
8871        let bytes = big_file_with_pyramid();
8872
8873        let full = CountingReader::new(SliceReader::new(&bytes));
8874        let rete = Rete::open_ranged(&full).unwrap();
8875        // Full open touches at most 4 ranges (header, dict, index, meta).
8876        assert!(full.requests() <= 4, "requests = {}", full.requests());
8877        assert_eq!(
8878            rete.query(Some("<http://ex/e0>"), None, None)[0].2,
8879            "<http://ex/e1>"
8880        );
8881
8882        // Summary-only open skips the index → strictly fewer bytes than the file.
8883        let summ_reader = CountingReader::new(SliceReader::new(&bytes));
8884        let view = SummaryView::open_ranged(&summ_reader).unwrap().unwrap();
8885        assert!(!view.summary.is_empty());
8886        assert!(
8887            summ_reader.bytes_read() < bytes.len() as u64,
8888            "summary read {} of {} bytes",
8889            summ_reader.bytes_read(),
8890            bytes.len()
8891        );
8892        // And fewer than a full open, since it never fetched the index.
8893        assert!(summ_reader.bytes_read() < full.bytes_read());
8894    }
8895
8896    #[test]
8897    fn content_hash_is_set_and_verifies() {
8898        let bytes = build_image();
8899        let rete = Rete::open(&bytes).unwrap();
8900        assert_ne!(
8901            rete.header().content_hash,
8902            [0u8; 16],
8903            "hash must be populated"
8904        );
8905        assert!(verify(&bytes).unwrap(), "freshly built file verifies");
8906        assert!(
8907            verify_ranged(&crate::reader::SliceReader::new(&bytes)).unwrap(),
8908            "the streaming verify agrees"
8909        );
8910
8911        // Same data builds an identical hash (deterministic).
8912        assert_eq!(
8913            Rete::open(&build_image()).unwrap().header().content_hash,
8914            rete.header().content_hash
8915        );
8916
8917        // Corrupting a payload byte breaks verification.
8918        let mut tampered = bytes.clone();
8919        let last = tampered.len() - 5; // inside payload, before footer magic
8920        tampered[last] ^= 0xff;
8921        assert!(!verify(&tampered).unwrap());
8922        assert!(!verify_ranged(&crate::reader::SliceReader::new(&tampered)).unwrap());
8923        // A truncated file is an error, never a pass.
8924        let short = &bytes[..bytes.len() / 2];
8925        assert!(verify_ranged(&crate::reader::SliceReader::new(short)).is_err());
8926        assert!(verify_ranged(&crate::reader::SliceReader::new(&bytes[..10])).is_err());
8927    }
8928
8929    /// Build the standard 3-triple image with an opaque metadata payload.
8930    fn build_with_metadata(meta: &[u8]) -> Vec<u8> {
8931        let triples = [
8932            ("Alice", "knows", "Bob"),
8933            ("Bob", "knows", "Carol"),
8934            ("Alice", "age", "30"),
8935        ];
8936        let mut db = DictionaryBuilder::new();
8937        for (s, p, o) in triples {
8938            db.observe(s, p, o);
8939        }
8940        let dict = db.build();
8941        let ids: Vec<_> = triples
8942            .iter()
8943            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
8944            .collect();
8945        let mut ib = GraphIndexBuilder::new();
8946        for &t in &ids {
8947            ib.push(t);
8948        }
8949        let (pmeta, levels) = build_pyramid_meta(&dict, &ids, DEFAULT_TILE_BUDGET);
8950        write_dataset_with_metadata(&dict, &ib.build(), &[], false, &pmeta, levels, meta, &[])
8951    }
8952
8953    #[test]
8954    fn metadata_round_trips_and_shifts_offsets() {
8955        let card = br#"{"title":"My Dataset"}"#;
8956        let bytes = build_with_metadata(card);
8957        let rete = Rete::open(&bytes).unwrap();
8958
8959        // The opaque payload reads back verbatim.
8960        assert_eq!(rete.metadata(), Some(card.as_slice()));
8961        let h = rete.header();
8962        assert_eq!(h.metadata_offset, HEADER_LEN as u64);
8963        assert_eq!(h.metadata_len, card.len() as u64);
8964        // The dictionary (and everything after it) shifted forward by the card.
8965        assert_eq!(h.dictionary_offset, HEADER_LEN as u64 + card.len() as u64);
8966
8967        // The index still decodes correctly at its shifted offset.
8968        assert_eq!(
8969            rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
8970            1
8971        );
8972        // The card is inside the content hash, so the file still verifies.
8973        assert!(verify(&bytes).unwrap());
8974    }
8975
8976    #[test]
8977    fn empty_metadata_is_byte_identical_to_plain_writer() {
8978        // The `&[]` path must produce exactly the bytes of the metadata-free
8979        // writer for identical inputs — old files and outputs are unchanged.
8980        assert_eq!(
8981            build_with_metadata(&[]),
8982            build_image(),
8983            "empty-metadata output must equal the plain writer byte-for-byte"
8984        );
8985    }
8986
8987    /// The contract `replace_metadata` has to meet to be usable at the end of a
8988    /// build: swapping the card must land on **exactly** the bytes the writer
8989    /// would have produced with that card in the first place — shorter, longer,
8990    /// and gone — so a spliced file and a rebuilt one are the same file.
8991    #[test]
8992    fn replace_metadata_matches_a_direct_write() {
8993        let first = br#"{"title":"My Dataset","queries":[1,2,3]}"#;
8994        let bytes = build_with_metadata(first);
8995        for target in [
8996            br#"{"title":"My Dataset","queries":[1,2]}"#.as_slice(), // shorter
8997            br#"{"title":"My Dataset","queries":[1,2,3,4,5,6,7]}"#.as_slice(), // longer
8998            b"".as_slice(),                                          // removed
8999            first.as_slice(),                                        // unchanged
9000        ] {
9001            let spliced = replace_metadata(&bytes, target).unwrap();
9002            assert_eq!(
9003                spliced,
9004                build_with_metadata(target),
9005                "splicing {} bytes of metadata must equal a direct write",
9006                target.len()
9007            );
9008            assert!(
9009                verify(&spliced).unwrap(),
9010                "the new hash covers the new card"
9011            );
9012            let rete = Rete::open(&spliced).unwrap();
9013            assert_eq!(
9014                rete.metadata(),
9015                (!target.is_empty()).then_some(target),
9016                "the new payload reads back verbatim"
9017            );
9018            // The data is untouched by the surgery.
9019            assert_eq!(
9020                rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
9021                1
9022            );
9023        }
9024    }
9025
9026    /// A build-info section survives the splice, still adjacent to the card, and
9027    /// still outside the content hash.
9028    #[test]
9029    fn replace_metadata_keeps_build_info_adjacent() {
9030        let with_info =
9031            attach_build_info(&build_with_metadata(br#"{"a":1}"#), br#"{"builder":"x"}"#).unwrap();
9032        let spliced = replace_metadata(&with_info, br#"{"a":1,"b":2}"#).unwrap();
9033        let h = Header::from_bytes(&spliced).unwrap();
9034        assert_eq!(h.metadata_offset, HEADER_LEN as u64);
9035        assert_eq!(h.build_info_offset, HEADER_LEN as u64 + h.metadata_len);
9036        assert_eq!(
9037            read_build_info(&spliced).unwrap().as_deref(),
9038            Some(br#"{"builder":"x"}"#.as_slice())
9039        );
9040        assert!(verify(&spliced).unwrap());
9041        // Same card, same content hash, whatever the build-info says.
9042        assert_eq!(
9043            h.content_hash,
9044            Header::from_bytes(&build_with_metadata(br#"{"a":1,"b":2}"#))
9045                .unwrap()
9046                .content_hash
9047        );
9048    }
9049
9050    #[test]
9051    fn metadata_is_tamper_evident() {
9052        let card = br#"{"title":"x"}"#;
9053        let mut bytes = build_with_metadata(card);
9054        assert!(verify(&bytes).unwrap());
9055        // The card occupies [HEADER_LEN .. HEADER_LEN+card_len); flip a byte in it.
9056        bytes[HEADER_LEN + 2] ^= 0xff;
9057        assert!(
9058            !verify(&bytes).unwrap(),
9059            "tampering with the card must break verify()"
9060        );
9061    }
9062
9063    #[test]
9064    fn ranged_opens_do_not_fetch_metadata() {
9065        use crate::reader::{CountingReader, SliceReader};
9066        let card = vec![0xABu8; 512]; // distinctive and sizable
9067        let bytes = build_with_metadata(&card);
9068        let total = bytes.len() as u64;
9069
9070        // A full ranged open never loads the card and never reads its byte range.
9071        let r = CountingReader::new(SliceReader::new(&bytes));
9072        let rete = Rete::open_ranged(&r).unwrap();
9073        assert!(
9074            rete.metadata().is_none(),
9075            "open_ranged must not load the card"
9076        );
9077        assert!(r.requests() <= 4, "requests = {}", r.requests());
9078        assert!(
9079            r.bytes_read() <= total - card.len() as u64,
9080            "read {} of {} bytes; the {}-byte card must be skipped",
9081            r.bytes_read(),
9082            total,
9083            card.len()
9084        );
9085
9086        // Summary-only open likewise ignores the card and still summarizes.
9087        let rs = CountingReader::new(SliceReader::new(&bytes));
9088        let view = SummaryView::open_ranged(&rs).unwrap().unwrap();
9089        assert!(!view.summary.is_empty());
9090        assert!(rs.bytes_read() <= total - card.len() as u64);
9091    }
9092
9093    #[test]
9094    fn metadata_ranged_fetches_only_header_and_card() {
9095        use crate::reader::{CountingReader, SliceReader};
9096        // The CARD tier: fetch the self-description over a RangeReader touching
9097        // only the header + metadata range — never the dictionary/index/pyramid.
9098        let card = vec![0xCDu8; 384];
9099        let bytes = build_with_metadata(&card);
9100
9101        let r = CountingReader::new(SliceReader::new(&bytes));
9102        let got = read_metadata_ranged(&r).unwrap().unwrap();
9103        assert_eq!(got, card, "the card reads back verbatim");
9104        assert_eq!(r.requests(), 2, "exactly header + metadata ranges");
9105        assert_eq!(
9106            r.bytes_read(),
9107            HEADER_LEN as u64 + card.len() as u64,
9108            "no dictionary/index/pyramid bytes are touched"
9109        );
9110
9111        // A cardless file resolves to None after a single header read.
9112        let plain = build_image();
9113        let rp = CountingReader::new(SliceReader::new(&plain));
9114        assert!(read_metadata_ranged(&rp).unwrap().is_none());
9115        assert_eq!(rp.requests(), 1, "header only for a cardless file");
9116        assert_eq!(rp.bytes_read(), HEADER_LEN as u64);
9117    }
9118
9119    #[test]
9120    fn build_info_attaches_outside_the_hash() {
9121        let card = br#"{"title":"My Dataset"}"#;
9122        let base = build_with_metadata(card);
9123        let base_hash = Header::from_bytes(&base).unwrap().content_hash;
9124
9125        let info = br#"{"built_at":"2026-08-04T00:00:00Z","builder":"rete-cli 0.3.2"}"#;
9126        let with = attach_build_info(&base, info).unwrap();
9127
9128        // The section reads back verbatim, adjacent to the metadata.
9129        assert_eq!(read_build_info(&with).unwrap().unwrap(), info);
9130        let h = Header::from_bytes(&with).unwrap();
9131        assert_eq!(h.build_info_offset, HEADER_LEN as u64 + card.len() as u64);
9132        assert_eq!(h.build_info_len, info.len() as u64);
9133        assert_eq!(h.expected_file_len(), Some(with.len() as u64));
9134
9135        // OUTSIDE the hash: the content hash is unchanged and the file still
9136        // verifies — two builds of identical data stay hash-equal even though
9137        // their build-info differs.
9138        assert_eq!(h.content_hash, base_hash);
9139        assert!(verify(&with).unwrap());
9140        let other = attach_build_info(&base, br#"{"built_at":"1999-01-01T00:00:00Z"}"#).unwrap();
9141        assert_eq!(Header::from_bytes(&other).unwrap().content_hash, base_hash);
9142
9143        // The graph still opens and answers at its shifted offsets.
9144        let rete = Rete::open(&with).unwrap();
9145        assert_eq!(
9146            rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
9147            1
9148        );
9149        assert_eq!(rete.metadata(), Some(card.as_slice()));
9150
9151        // Replacing an existing section works and removing it restores the
9152        // original image byte-for-byte (the strip-equality determinism proof).
9153        let replaced = attach_build_info(&with, br#"{"builder":"other"}"#).unwrap();
9154        assert_eq!(
9155            read_build_info(&replaced).unwrap().unwrap(),
9156            br#"{"builder":"other"}"#
9157        );
9158        assert!(verify(&replaced).unwrap());
9159        let stripped = attach_build_info(&replaced, &[]).unwrap();
9160        assert_eq!(stripped, base, "stripping build-info restores the image");
9161    }
9162
9163    #[test]
9164    fn build_info_attaches_on_a_metadata_free_file() {
9165        // No card: the section slots in right after the header.
9166        let base = build_image();
9167        let info = b"{\"builder\":\"x\"}";
9168        let with = attach_build_info(&base, info).unwrap();
9169        let h = Header::from_bytes(&with).unwrap();
9170        assert_eq!(h.build_info_offset, HEADER_LEN as u64);
9171        assert_eq!(h.metadata_len, 0);
9172        assert_eq!(read_build_info(&with).unwrap().unwrap(), info);
9173        assert!(verify(&with).unwrap());
9174        assert_eq!(
9175            h.content_hash,
9176            Header::from_bytes(&base).unwrap().content_hash
9177        );
9178        Rete::open(&with).unwrap();
9179    }
9180
9181    /// The contract a **streaming** rewriter needs: a plan is enough to rebuild
9182    /// the file from three spans — the header it hands back, the untouched
9183    /// `[HEADER_LEN, insert)` prefix, the new section, and the untouched
9184    /// `[tail_start, len)` tail — and the result is byte-identical to the
9185    /// in-memory splice. That is what lets a 17 GB file gain a build record
9186    /// with a 4 MiB buffer instead of 34 GB of RAM.
9187    #[test]
9188    fn a_plan_rebuilds_exactly_what_the_in_memory_splice_produces() {
9189        let card = br#"{"title":"My Dataset"}"#;
9190        let base = build_with_metadata(card);
9191        let base_hash = Header::from_bytes(&base).unwrap().content_hash;
9192        for start in [
9193            base.clone(),
9194            attach_build_info(&base, b"{\"a\":1}").unwrap(),
9195        ] {
9196            for info in [
9197                br#"{"builder":"rete-cli 0.3.2","query_costs":{}}"#.as_slice(), // longer
9198                b"{}".as_slice(),                                               // shorter
9199                b"".as_slice(),                                                 // removed
9200            ] {
9201                let plan = plan_build_info(&start, start.len() as u64, info.len() as u64).unwrap();
9202                let mut streamed = Vec::new();
9203                streamed.extend_from_slice(&plan.header);
9204                streamed.extend_from_slice(&start[HEADER_LEN..plan.insert as usize]);
9205                streamed.extend_from_slice(info);
9206                streamed.extend_from_slice(&start[plan.tail_start as usize..]);
9207                assert_eq!(streamed.len() as u64, plan.new_len);
9208                assert_eq!(
9209                    streamed,
9210                    attach_build_info(&start, info).unwrap(),
9211                    "a {}-byte section streamed must equal the same section spliced",
9212                    info.len()
9213                );
9214                // The point of the exercise: the file's identity is untouched.
9215                assert!(verify(&streamed).unwrap());
9216                let h = Header::from_bytes(&streamed).unwrap();
9217                assert_eq!(h.content_hash, base_hash);
9218                assert_eq!(h.expected_file_len(), Some(plan.new_len));
9219                let rete = Rete::open(&streamed).unwrap();
9220                assert_eq!(rete.metadata(), Some(card.as_slice()));
9221                assert_eq!(
9222                    rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
9223                    1
9224                );
9225            }
9226        }
9227    }
9228
9229    /// A plan is refused rather than trusted when the header's own numbers put
9230    /// the section outside the file — the header is attacker-controlled input.
9231    #[test]
9232    fn a_plan_refuses_a_header_that_overruns_the_file() {
9233        let base = build_with_metadata(br#"{"title":"x"}"#);
9234        assert!(plan_build_info(&base, 8, 16).is_err());
9235        assert!(plan_build_info(&base[..HEADER_LEN], base.len() as u64, 16).is_ok());
9236    }
9237
9238    #[test]
9239    fn card_and_build_info_ranged_is_one_header_plus_one_range() {
9240        use crate::reader::{CountingReader, SliceReader};
9241        let card = vec![0xCDu8; 384];
9242        let info = vec![0xEFu8; 200];
9243        let bytes = attach_build_info(&build_with_metadata(&card), &info).unwrap();
9244
9245        // Both sections in ONE coalesced range after the header — the CARD
9246        // tier's 1 header + 1 range budget holds with build info included.
9247        let r = CountingReader::new(SliceReader::new(&bytes));
9248        let (m, b) = read_card_and_build_info_ranged(&r).unwrap();
9249        assert_eq!(m.unwrap(), card);
9250        assert_eq!(b.unwrap(), info);
9251        assert_eq!(r.requests(), 2, "header + one coalesced range");
9252        assert_eq!(
9253            r.bytes_read(),
9254            HEADER_LEN as u64 + card.len() as u64 + info.len() as u64
9255        );
9256
9257        // Card only: same two requests, no build info.
9258        let plain = build_with_metadata(&card);
9259        let rp = CountingReader::new(SliceReader::new(&plain));
9260        let (m, b) = read_card_and_build_info_ranged(&rp).unwrap();
9261        assert_eq!(m.unwrap(), card);
9262        assert!(b.is_none());
9263        assert_eq!(rp.requests(), 2);
9264        // Stated as the comparison every client depends on: carrying a build
9265        // record costs a reader NOTHING in requests. Any client that shows the
9266        // build record — the CLI, the browser card modal — can therefore do it
9267        // in the card's own budget, and does not need a second call.
9268        assert_eq!(
9269            r.requests(),
9270            rp.requests(),
9271            "build info must not cost an extra request",
9272        );
9273
9274        // Neither: the header read alone answers.
9275        let none = build_image();
9276        let rn = CountingReader::new(SliceReader::new(&none));
9277        let (m, b) = read_card_and_build_info_ranged(&rn).unwrap();
9278        assert!(m.is_none() && b.is_none());
9279        assert_eq!(rn.requests(), 1);
9280    }
9281
9282    #[test]
9283    fn schema_summary_groups_by_type() {
9284        let rt = RDF_TYPE;
9285        let bytes = build_from(&[
9286            ("Alice", rt, "Person"),
9287            ("Bob", rt, "Person"),
9288            ("NYC", rt, "City"),
9289            ("Alice", "knows", "Bob"),
9290            ("Alice", "livesIn", "NYC"),
9291            ("Alice", "name", "\"Alice\""),
9292        ]);
9293        let rete = Rete::open(&bytes).unwrap();
9294        let summary = schema_summary(&rete);
9295        // Expect class-level relations, rdf:type excluded.
9296        assert!(summary.contains(&("Person".into(), "knows".into(), "Person".into(), 1)));
9297        assert!(summary.contains(&("Person".into(), "livesIn".into(), "City".into(), 1)));
9298        assert!(summary.contains(&("Person".into(), "name".into(), "(literal)".into(), 1)));
9299        // No rdf:type relations in the summary.
9300        assert!(!summary.iter().any(|(_, p, _, _)| p == RDF_TYPE));
9301
9302        // Class populations: 2 People, 1 City, sorted by count desc.
9303        let classes = schema_classes(&rete);
9304        assert_eq!(
9305            classes,
9306            vec![("Person".into(), 2u32), ("City".into(), 1u32)]
9307        );
9308    }
9309
9310    fn build_from(triples: &[(&str, &str, &str)]) -> Vec<u8> {
9311        let mut db = DictionaryBuilder::new();
9312        for (s, p, o) in triples {
9313            db.observe(s, p, o);
9314        }
9315        let dict = db.build();
9316        let mut ib = GraphIndexBuilder::new();
9317        for (s, p, o) in triples {
9318            ib.push(dict.encode(s, p, o).unwrap());
9319        }
9320        write_file(&dict, &ib.build(), false, &[], 0)
9321    }
9322
9323    /// Build a file WITH a pyramid (so the schema pyramid + coherence axioms ship).
9324    fn build_with_pyramid(triples: &[(&str, &str, &str)]) -> Vec<u8> {
9325        let mut db = DictionaryBuilder::new();
9326        for (s, p, o) in triples {
9327            db.observe(s, p, o);
9328        }
9329        let dict = db.build();
9330        let encoded: Vec<_> = triples
9331            .iter()
9332            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
9333            .collect();
9334        let mut ib = GraphIndexBuilder::new();
9335        for t in &encoded {
9336            ib.push(*t);
9337        }
9338        let (meta, levels) = build_pyramid_meta(&dict, &encoded, DEFAULT_TILE_BUDGET);
9339        write_dataset(&dict, &ib.build(), &[], false, &meta, levels)
9340    }
9341
9342    #[test]
9343    fn tbox_coherence_flags_unsatisfiable_class_index_free() {
9344        use crate::reader::{CountingReader, SliceReader};
9345        let rt = RDF_TYPE;
9346        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
9347        let disj = "<http://www.w3.org/2002/07/owl#disjointWith>";
9348        // C ⊑ D, C ⊑ E, D disjointWith E ⇒ C unsatisfiable — schema-only, with one
9349        // instance present just so the schema pyramid gets built.
9350        let bytes = build_with_pyramid(&[
9351            ("<http://ex/C>", sub, "<http://ex/D>"),
9352            ("<http://ex/C>", sub, "<http://ex/E>"),
9353            ("<http://ex/D>", disj, "<http://ex/E>"),
9354            ("<http://ex/x>", rt, "<http://ex/C>"),
9355        ]);
9356
9357        let r = CountingReader::new(SliceReader::new(&bytes));
9358        let view = SummaryView::open_ranged(&r).unwrap().unwrap();
9359        let points = view.tbox_coherence();
9360        assert!(
9361            points
9362                .iter()
9363                .any(|i| i.kind == "unsatisfiable-class" && i.detail.contains("http://ex/C>")),
9364            "expected C unsatisfiable from the schema alone, got {points:?}"
9365        );
9366
9367        // Proven index-free: bytes read never reach the (root_dir) index section,
9368        // mirroring `schema_pyramid_round_trips_through_file_index_free`.
9369        let header = Header::from_bytes(&bytes[..HEADER_LEN]).unwrap();
9370        assert!(
9371            r.bytes_read() <= bytes.len() as u64 - header.root_dir_len,
9372            "tbox_coherence must not read the triple index"
9373        );
9374    }
9375
9376    #[test]
9377    fn schema_coherence_reads_only_the_schema_block() {
9378        use crate::reader::{CountingReader, SliceReader};
9379        let rt = RDF_TYPE;
9380        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
9381        let disj = "<http://www.w3.org/2002/07/owl#disjointWith>";
9382        // 500 instances with unique literals → a sizable dictionary + community
9383        // summary, so a whole-pyramid-meta read would be large; the schema block
9384        // (bounded by the tiny ontology) stays small.
9385        let mut triples: Vec<(String, String, String)> = vec![
9386            ("<http://ex/C>".into(), sub.into(), "<http://ex/D>".into()),
9387            ("<http://ex/C>".into(), sub.into(), "<http://ex/E>".into()),
9388            ("<http://ex/D>".into(), disj.into(), "<http://ex/E>".into()),
9389        ];
9390        for i in 0..500 {
9391            let s = format!("<http://ex/x{i}>");
9392            triples.push((s.clone(), rt.into(), "<http://ex/C>".into()));
9393            triples.push((
9394                s,
9395                "<http://ex/label>".into(),
9396                format!("\"unique label {i}\""),
9397            ));
9398        }
9399        let trefs: Vec<(&str, &str, &str)> = triples
9400            .iter()
9401            .map(|(s, p, o)| (s.as_str(), p.as_str(), o.as_str()))
9402            .collect();
9403        let bytes = build_with_pyramid(&trefs);
9404
9405        let header = Header::from_bytes(&bytes[..HEADER_LEN]).unwrap();
9406        assert!(
9407            header.schema_meta_len > 0,
9408            "the writer recorded a schema-block length"
9409        );
9410        assert!(
9411            (header.schema_meta_len as u64) < header.pyramid_meta_len,
9412            "schema block ({}) should be far smaller than the whole pyramid-meta ({})",
9413            header.schema_meta_len,
9414            header.pyramid_meta_len
9415        );
9416
9417        let r = CountingReader::new(SliceReader::new(&bytes));
9418        let points = read_schema_coherence_ranged(&r).unwrap().unwrap();
9419        assert!(points.iter().any(|i| i.kind == "unsatisfiable-class"));
9420        // It read only the header + the schema block — not the summary or dictionary.
9421        assert!(
9422            r.bytes_read() <= HEADER_LEN as u64 + header.schema_meta_len as u64,
9423            "read {} bytes; expected <= header + schema block ({})",
9424            r.bytes_read(),
9425            HEADER_LEN as u64 + header.schema_meta_len as u64
9426        );
9427    }
9428
9429    #[test]
9430    fn tbox_coherence_clean_schema_is_coherent() {
9431        use crate::reader::SliceReader;
9432        let rt = RDF_TYPE;
9433        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
9434        let bytes = build_with_pyramid(&[
9435            ("<http://ex/Dog>", sub, "<http://ex/Animal>"),
9436            ("<http://ex/x>", rt, "<http://ex/Dog>"),
9437        ]);
9438        let view = SummaryView::open_ranged(&SliceReader::new(&bytes))
9439            .unwrap()
9440            .unwrap();
9441        assert!(view.tbox_is_coherent(), "a plain hierarchy is coherent");
9442    }
9443
9444    /// A quad-index file must be **refused** by every reader that predates it —
9445    /// an old reader ignores flag bit 3 and the unknown kind 10, so it would
9446    /// otherwise open the file as one without named graphs and answer `GRAPH`
9447    /// queries with nothing. The seal makes each of their open paths fail on the
9448    /// index container's section count, before any payload is read; their
9449    /// `verify` still passes, because the part list it hashes is unchanged.
9450    /// (Checked against real 0.3.x binaries too: QUAD-INDEX-DESIGN.md §9.)
9451    #[test]
9452    fn readers_before_the_quad_index_refuse_its_files() {
9453        for perms in [PermSet::ALL, PermSet::CORE] {
9454            let quads: Vec<crate::ingest::RawQuad> = (0..50)
9455                .map(|i| {
9456                    (
9457                        format!("<http://ex/s{i}>"),
9458                        "<http://ex/p>".to_string(),
9459                        format!("\"{i}\""),
9460                        Some(format!("<http://ex/g{}>", i % 7)),
9461                    )
9462                })
9463                .collect();
9464            let (bytes, _) = crate::ingest::assemble_dataset_with_layout(
9465                quads,
9466                false,
9467                false,
9468                None,
9469                crate::PyramidAlgo::Louvain,
9470                perms,
9471                crate::ingest::GraphLayout::Quad,
9472                |_, _| Vec::<u8>::new(),
9473            );
9474            let h = Header::from_bytes(&bytes).unwrap();
9475            assert!(h.has_quad_index());
9476            let root =
9477                &bytes[h.root_dir_offset as usize..(h.root_dir_offset + h.root_dir_len) as usize];
9478            // 0.3.4–0.3.7, resident (`Rete::open`, `open_ranged`): count != mask.
9479            assert!(decode_index_container(root, h.block_codec, h.perms).is_err());
9480            assert!(
9481                container_section_payload_ranges(root, h.root_dir_offset, h.perms.len()).is_err()
9482            );
9483            // 0.3.0–0.3.3 expected exactly six sections, whatever the mask.
9484            assert_ne!(decode_container(root, CODEC_NONE).unwrap().len(), NUM_PERMS);
9485            assert_ne!(
9486                decode_container(root, CODEC_NONE).unwrap().len(),
9487                h.perms.len()
9488            );
9489            // Every lazy / remote open (`open_ranged_lazy`, `query_ranged`).
9490            let r = crate::reader::SliceReader::new(&bytes);
9491            for expect in [h.perms.len(), NUM_PERMS] {
9492                assert!(locate_container_section_ranged(
9493                    &r,
9494                    h.root_dir_offset,
9495                    h.root_dir_len,
9496                    0,
9497                    expect as u64
9498                )
9499                .is_err());
9500            }
9501            // Their `verify` hashes the same parts the writer hashed.
9502            assert_eq!(hash_of_sections(&h, &bytes).unwrap(), h.content_hash);
9503            // And a current reader reads the file.
9504            let rete = Rete::open(&bytes).unwrap();
9505            assert_eq!(rete.named_graph_count(), 7);
9506            assert_eq!(rete.dump(Some("<http://ex/g3>")).len(), 7);
9507        }
9508    }
9509
9510    #[test]
9511    fn named_graphs_round_trip() {
9512        // One shared dictionary; default graph + a named graph "g1".
9513        let all = [
9514            ("Alice", "knows", "Bob"), // default
9515            ("Bob", "age", "30"),      // named g1
9516        ];
9517        let mut db = DictionaryBuilder::new();
9518        for (s, p, o) in all {
9519            db.observe(s, p, o);
9520        }
9521        let dict = db.build();
9522
9523        let mut def = GraphIndexBuilder::new();
9524        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
9525        let mut g1 = GraphIndexBuilder::new();
9526        g1.push(dict.encode("Bob", "age", "30").unwrap());
9527
9528        let named = vec![("http://ex/g1".to_string(), g1.build())];
9529        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
9530
9531        assert!(verify(&bytes).unwrap());
9532        let rete = Rete::open(&bytes).unwrap();
9533        assert_eq!(rete.graph_names(), vec!["http://ex/g1"]);
9534        // The named graph contains Bob age 30, not the default-graph triple.
9535        let gi = rete.graph_index("http://ex/g1").unwrap();
9536        assert_eq!(gi.triple_count(), 1);
9537        assert!(rete.graph_index("http://ex/missing").is_none());
9538
9539        // quad_count counts ALL quads — default graph + named graphs (1 + 1),
9540        // not just the default index (which would report 1).
9541        assert_eq!(rete.header().quad_count, 2);
9542
9543        // Default-graph query path is unchanged.
9544        assert_eq!(rete.query(Some("Alice"), None, None).len(), 1);
9545
9546        // dump() round-trips each graph back to terms.
9547        assert_eq!(
9548            rete.dump(None),
9549            vec![("Alice".into(), "knows".into(), "Bob".into())]
9550        );
9551        assert_eq!(
9552            rete.dump(Some("http://ex/g1")),
9553            vec![("Bob".into(), "age".into(), "30".into())]
9554        );
9555    }
9556
9557    /// A named graph whose container exceeds [`NAMED_GRAPH_RESIDENT_MAX`]
9558    /// opens through [`open_index_container_lazy`] — the default graph's own
9559    /// machinery, at the container's offset. Drive that helper directly over a
9560    /// named graph's container range (the threshold itself is too large to
9561    /// cross cheaply in a unit test) and require identical matches.
9562    #[test]
9563    fn named_graph_container_opens_tile_lazily_like_the_root() {
9564        let mut db = DictionaryBuilder::new();
9565        let node = |n: u32| format!("<http://ex/n{n}>");
9566        let p = "<http://ex/p>".to_string();
9567        db.observe(&node(0), &p, &node(1));
9568        for i in 0..500u32 {
9569            db.observe(&node(1000 + i), &p, &node(2000 + i));
9570        }
9571        let dict = db.build();
9572        let mut def = GraphIndexBuilder::new();
9573        def.push(dict.encode(&node(0), &p, &node(1)).unwrap());
9574        let mut g = GraphIndexBuilder::new().with_tile_budget(256); // many tiles
9575        for i in 0..500u32 {
9576            g.push(dict.encode(&node(1000 + i), &p, &node(2000 + i)).unwrap());
9577        }
9578        let named = vec![("<http://ex/g>".to_string(), g.build())];
9579        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
9580        let rete = Rete::open(&bytes).unwrap();
9581        let header = rete.header();
9582
9583        // The single entry's container range: count varint, iri_len varint,
9584        // iri, container_len varint, then the container.
9585        let soff = header.named_graphs_offset as usize;
9586        let send = soff + header.named_graphs_len as usize;
9587        let sec = &bytes[soff..send];
9588        let (n, mut pos) = read_uvarint(sec).unwrap();
9589        assert_eq!(n, 1);
9590        let (ilen, u1) = read_uvarint(&sec[pos..]).unwrap();
9591        pos += u1 + ilen as usize;
9592        let (clen, u2) = read_uvarint(&sec[pos..]).unwrap();
9593        pos += u2;
9594        let container = ByteRange {
9595            offset: (soff + pos) as u64,
9596            len: clen,
9597        };
9598
9599        use crate::reader::SliceReader;
9600        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
9601        let reader: std::sync::Arc<dyn RangeReader + Send + Sync> =
9602            std::sync::Arc::new(SliceReader::new(leaked));
9603        let (lazy_idx, _, _) = open_index_container_lazy(
9604            &reader,
9605            container,
9606            header.block_codec,
9607            header.has_tile_synopsis(),
9608            1,
9609            header.perms,
9610            header.perms.len(),
9611        )
9612        .unwrap();
9613        let mut want = rete
9614            .graph_index("<http://ex/g>")
9615            .unwrap()
9616            .match_pattern((None, None, None));
9617        let mut got = lazy_idx.match_pattern((None, None, None));
9618        want.sort_unstable();
9619        got.sort_unstable();
9620        assert_eq!(got.len(), 500);
9621        assert_eq!(got, want);
9622        assert!(!lazy_idx.load_incomplete());
9623    }
9624
9625    /// `dump_iter` must agree with `dump` exactly — and must be *lazy*: taking
9626    /// the first triple of a lazily range-read file may not drag the whole
9627    /// index across the reader, or the JS client's streaming quad cursor (which
9628    /// is this iterator, suspended between wasm calls) would be a materializing
9629    /// dump wearing a cursor's clothes.
9630    #[test]
9631    fn dump_iter_matches_dump_and_stops_early() {
9632        use crate::reader::{CountingReader, SliceReader};
9633
9634        // Enough triples over tiny tiles to force many tiles per permutation.
9635        let triples: Vec<(String, String, String)> = (0..2000u32)
9636            .map(|i| {
9637                (
9638                    format!("<http://ex/s/{i:04}>"),
9639                    "<http://ex/p>".to_string(),
9640                    format!("<http://ex/o/{i:04}>"),
9641                )
9642            })
9643            .collect();
9644        let mut db = DictionaryBuilder::new();
9645        for (s, p, o) in &triples {
9646            db.observe(s, p, o);
9647        }
9648        let dict = db.build();
9649        let mut ib = GraphIndexBuilder::new().with_tile_budget(256);
9650        for (s, p, o) in &triples {
9651            ib.push(dict.encode(s, p, o).unwrap());
9652        }
9653        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
9654
9655        // Same triples, same order, as the eager dump.
9656        let rete = Rete::open(&bytes).unwrap();
9657        assert_eq!(rete.dump_iter(None).collect::<Vec<_>>(), rete.dump(None));
9658        // A missing named graph yields nothing rather than the default graph.
9659        assert_eq!(rete.dump_iter(Some("http://ex/nope")).count(), 0);
9660
9661        // Lazily: taking one triple must cost far fewer bytes than draining.
9662        let read_bytes = |take: usize| -> u64 {
9663            let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
9664            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
9665            let rete = Rete::open_ranged_lazy_with(reader.clone(), HEADER_LEN as u64).unwrap();
9666            let before = reader.bytes_read();
9667            let n = rete.dump_iter(None).take(take).count();
9668            assert_eq!(n, take.min(triples.len()));
9669            assert!(!rete.index_incomplete());
9670            reader.bytes_read() - before
9671        };
9672        let first_only = read_bytes(1);
9673        let everything = read_bytes(triples.len());
9674        // Always true: stopping after one triple cannot cost a full scan.
9675        assert!(
9676            first_only < everything,
9677            "dump_iter is not lazy: one triple read {first_only} B, a full scan {everything} B"
9678        );
9679        // The STRONG claim — under half — only holds once the payload dwarfs the
9680        // fixed open cost. This fixture is ~32 KB, and with `compression` off the
9681        // dictionary is essentially the whole file, so opening it at all already
9682        // pays most of `everything` (21 KB of 33 KB) and the ratio says nothing
9683        // about laziness. Asserting it unconditionally is how this test failed on
9684        // `--no-default-features` while passing everywhere else.
9685        #[cfg(feature = "compression")]
9686        assert!(
9687            first_only * 2 < everything,
9688            "dump_iter is not lazy: one triple read {first_only} B of the {everything} B a full scan reads"
9689        );
9690
9691        // A FRESH open: these assertions fault tiles and the dictionary in,
9692        // and the laziness measurement above counts bytes on a COLD reader —
9693        // sharing one `rete` makes first_only and everything both ~0, so the
9694        // comparison silently stops testing anything.
9695        let fresh = Rete::open(&bytes).unwrap();
9696        // `dump_batch` is the resumable form the language clients pull through.
9697        // Driven to exhaustion it must reproduce the eager dump EXACTLY — same
9698        // triples, same order — for any batch size, including one so small that
9699        // every call ends on a subject boundary before reaching it.
9700        let drain = |max_quads: usize| {
9701            let (mut out, mut cursor, mut calls) = (Vec::new(), 0u32, 0);
9702            loop {
9703                let (batch, next, done) = fresh.dump_batch(None, cursor, max_quads);
9704                out.extend(batch);
9705                cursor = next;
9706                calls += 1;
9707                assert!(calls < 10_000, "cursor failed to advance");
9708                if done {
9709                    break out;
9710                }
9711            }
9712        };
9713        let eager = fresh.dump(None);
9714        for max in [1usize, 7, 128, 100_000] {
9715            assert_eq!(drain(max), eager, "batch size {max} changed the dump");
9716        }
9717        // An unknown graph is an empty, already-finished dump — not an error and
9718        // not the default graph leaking through.
9719        let (t, _, done) = fresh.dump_batch(Some("http://ex/nope"), 0, 16);
9720        assert!(t.is_empty() && done);
9721    }
9722
9723    /// `query_batch` is `dump_batch` generalized to a PATTERN — the primitive the
9724    /// Java client's RDF4J cursor pulls through, one bounded batch per wasm call.
9725    /// Three things have to hold or the cursor is a materializing scan wearing a
9726    /// cursor's clothes: driven to exhaustion it must reproduce the eager
9727    /// `query_in_graph` for every pattern shape and every batch size; taking one
9728    /// row must cost far fewer bytes than draining; and every call must either
9729    /// yield a row or say `done`, so the caller can never spin.
9730    #[test]
9731    fn query_batch_matches_query_in_graph_and_stops_early() {
9732        use crate::reader::{CountingReader, SliceReader};
9733
9734        // Enough triples over tiny tiles that a scan spans many tiles, with two
9735        // predicates and a repeated subject so bound patterns have real fanout.
9736        let triples: Vec<(String, String, String)> = (0..2000u32)
9737            .map(|i| {
9738                (
9739                    format!("<http://ex/s/{:04}>", i / 2),
9740                    format!("<http://ex/p/{}>", i % 2),
9741                    format!("<http://ex/o/{i:04}>"),
9742                )
9743            })
9744            .collect();
9745        let mut db = DictionaryBuilder::new();
9746        for (s, p, o) in &triples {
9747            db.observe(s, p, o);
9748        }
9749        let dict = db.build();
9750        let mut ib = GraphIndexBuilder::new().with_tile_budget(256);
9751        for (s, p, o) in &triples {
9752            ib.push(dict.encode(s, p, o).unwrap());
9753        }
9754        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
9755        let rete = Rete::open(&bytes).unwrap();
9756
9757        let drain = |s: Option<&str>, p: Option<&str>, o: Option<&str>, max: usize| {
9758            let (mut out, mut cursor, mut calls) = (Vec::new(), 0u64, 0);
9759            loop {
9760                let (batch, next, done) = rete.query_batch(None, s, p, o, cursor, max);
9761                // The contract the client relies on: rows, or done. Never
9762                // "nothing yet, call again" — that is an unbounded spin.
9763                assert!(!batch.is_empty() || done, "empty non-final batch");
9764                out.extend(batch);
9765                cursor = next;
9766                calls += 1;
9767                assert!(calls < 20_000, "cursor failed to advance");
9768                if done {
9769                    break out;
9770                }
9771            }
9772        };
9773        let shapes: [(Option<&str>, Option<&str>, Option<&str>); 6] = [
9774            (None, None, None),
9775            (Some("<http://ex/s/0007>"), None, None),
9776            (None, Some("<http://ex/p/1>"), None),
9777            (None, None, Some("<http://ex/o/0042>")),
9778            (Some("<http://ex/s/0007>"), Some("<http://ex/p/0>"), None),
9779            (
9780                Some("<http://ex/s/0007>"),
9781                Some("<http://ex/p/0>"),
9782                Some("<http://ex/o/0014>"),
9783            ),
9784        ];
9785        for (s, p, o) in shapes {
9786            let eager = rete.query_in_graph(None, s, p, o);
9787            assert!(
9788                !eager.is_empty(),
9789                "fixture shape {s:?} {p:?} {o:?} matches nothing"
9790            );
9791            // The lazy pull form agrees exactly, order included.
9792            assert_eq!(
9793                rete.query_iter(None, s, p, o).collect::<Vec<_>>(),
9794                eager,
9795                "query_iter disagrees for {s:?} {p:?} {o:?}"
9796            );
9797            for max in [1usize, 3, 64, 100_000] {
9798                let mut got = drain(s, p, o, max);
9799                let mut want = eager.clone();
9800                // A batch is re-sorted canonically per batch, not globally, so
9801                // a bound pattern's batches can arrive in permutation order.
9802                got.sort();
9803                want.sort();
9804                assert_eq!(got, want, "batch size {max} changed {s:?} {p:?} {o:?}");
9805            }
9806            // The fully unbound scan routes to SPO, so batching is order-exact.
9807            if s.is_none() && p.is_none() && o.is_none() {
9808                assert_eq!(drain(s, p, o, 7), eager, "unbound batching reordered rows");
9809            }
9810        }
9811
9812        // A bound term the dictionary has never seen, and an unknown graph, are
9813        // both finished-and-empty rather than errors or a leak of everything.
9814        let (t, _, done) = rete.query_batch(None, Some("<http://ex/nope>"), None, None, 0, 16);
9815        assert!(t.is_empty() && done);
9816        let (t, _, done) = rete.query_batch(Some("http://ex/nope"), None, None, None, 0, 16);
9817        assert!(t.is_empty() && done);
9818        assert_eq!(
9819            rete.query_iter(None, Some("<http://ex/nope>"), None, None)
9820                .count(),
9821            0
9822        );
9823
9824        // Lazily: one row must not drag the whole scan across the reader. This
9825        // is the property that makes `LIMIT 1` bounded on a 48 GiB file.
9826        let read_bytes = |take: usize| -> u64 {
9827            let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
9828            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
9829            let rete = Rete::open_ranged_lazy_with(reader.clone(), HEADER_LEN as u64).unwrap();
9830            let before = reader.bytes_read();
9831            let (mut n, mut cursor) = (0usize, 0u64);
9832            loop {
9833                let (batch, next, done) = rete.query_batch(None, None, None, None, cursor, 1);
9834                n += batch.len();
9835                cursor = next;
9836                if done || n >= take {
9837                    break;
9838                }
9839            }
9840            assert!(n >= take.min(triples.len()));
9841            assert!(!rete.index_incomplete());
9842            reader.bytes_read() - before
9843        };
9844        let first_only = read_bytes(1);
9845        let everything = read_bytes(triples.len());
9846        assert!(
9847            first_only < everything,
9848            "query_batch is not lazy: one row read {first_only} B, a full scan {everything} B"
9849        );
9850        #[cfg(feature = "compression")]
9851        assert!(
9852            first_only * 2 < everything,
9853            "query_batch is not lazy: one row read {first_only} B of the {everything} B a full scan reads"
9854        );
9855    }
9856
9857    /// A batched cursor over a QUADS file must stay inside the graph it was
9858    /// given: the default graph and each named graph resume independently.
9859    #[test]
9860    fn query_batch_is_graph_scoped() {
9861        let mut db = DictionaryBuilder::new();
9862        for (s, p, o) in [
9863            ("Alice", "knows", "Bob"),
9864            ("Alice", "knows", "Carol"),
9865            ("Alice", "knows", "Dave"),
9866            ("Erin", "knows", "Frank"),
9867            ("Alice", "knows", "Zoe"),
9868        ] {
9869            db.observe(s, p, o);
9870        }
9871        let dict = db.build();
9872        let mut def = GraphIndexBuilder::new();
9873        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
9874        def.push(dict.encode("Alice", "knows", "Carol").unwrap());
9875        let mut g1 = GraphIndexBuilder::new();
9876        g1.push(dict.encode("Alice", "knows", "Dave").unwrap());
9877        g1.push(dict.encode("Erin", "knows", "Frank").unwrap());
9878        let mut g2 = GraphIndexBuilder::new();
9879        g2.push(dict.encode("Alice", "knows", "Zoe").unwrap());
9880        let named = vec![
9881            ("http://ex/g1".to_string(), g1.build()),
9882            ("http://ex/g2".to_string(), g2.build()),
9883        ];
9884        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
9885        let rete = Rete::open(&bytes).unwrap();
9886        for graph in [None, Some("http://ex/g1"), Some("http://ex/g2")] {
9887            let eager = rete.query_in_graph(graph, None, None, None);
9888            let (mut out, mut cursor) = (Vec::new(), 0u64);
9889            loop {
9890                let (batch, next, done) = rete.query_batch(graph, None, None, None, cursor, 1);
9891                out.extend(batch);
9892                cursor = next;
9893                if done {
9894                    break;
9895                }
9896            }
9897            assert_eq!(out, eager, "graph {graph:?} batched differently");
9898        }
9899    }
9900
9901    #[test]
9902    fn query_in_graph_is_graph_scoped() {
9903        // Default graph: Alice knows Bob, Alice knows Carol.
9904        // Named g1: Alice knows Dave (same predicate, different graph).
9905        let mut db = DictionaryBuilder::new();
9906        for (s, p, o) in [
9907            ("Alice", "knows", "Bob"),
9908            ("Alice", "knows", "Carol"),
9909            ("Alice", "knows", "Dave"),
9910        ] {
9911            db.observe(s, p, o);
9912        }
9913        let dict = db.build();
9914
9915        let mut def = GraphIndexBuilder::new();
9916        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
9917        def.push(dict.encode("Alice", "knows", "Carol").unwrap());
9918        let mut g1 = GraphIndexBuilder::new();
9919        g1.push(dict.encode("Alice", "knows", "Dave").unwrap());
9920
9921        let named = vec![("http://ex/g1".to_string(), g1.build())];
9922        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
9923        let rete = Rete::open(&bytes).unwrap();
9924
9925        // Default graph only: the two default-graph objects, not Dave.
9926        let mut def_objs: Vec<String> = rete
9927            .query_in_graph(None, Some("Alice"), Some("knows"), None)
9928            .into_iter()
9929            .map(|(_, _, o)| o)
9930            .collect();
9931        def_objs.sort();
9932        assert_eq!(def_objs, vec!["Bob".to_string(), "Carol".to_string()]);
9933
9934        // Named graph only: just Dave.
9935        assert_eq!(
9936            rete.query_in_graph(Some("http://ex/g1"), Some("Alice"), None, None),
9937            vec![("Alice".into(), "knows".into(), "Dave".into())]
9938        );
9939
9940        // A wildcard-everything scan is scoped to its graph.
9941        assert_eq!(rete.query_in_graph(None, None, None, None).len(), 2);
9942        assert_eq!(
9943            rete.query_in_graph(Some("http://ex/g1"), None, None, None)
9944                .len(),
9945            1
9946        );
9947
9948        // An unknown graph IRI is empty, not an error.
9949        assert!(rete
9950            .query_in_graph(Some("http://ex/missing"), None, None, None)
9951            .is_empty());
9952    }
9953
9954    /// A **bounded** `query_in_graph` — and a dump of one named graph — must not
9955    /// fault the whole dictionary on a lazily range-read file.
9956    ///
9957    /// Both used to call `Dictionary::prefetch_all`, so the graph-scoped pattern
9958    /// primitive an RDF4J `Sail` calls once per named graph paid for every term
9959    /// in the file to answer a handful of rows. Measured on `cordis.rete`
9960    /// (801 MB, a 417 MB dictionary) before the fix: a 15-row answer read
9961    /// 416 MB and peaked at 2.5 GB RSS. The fixture below is the same shape in
9962    /// miniature — long literals so the dictionary dominates the file — and the
9963    /// assertion is the invariant that regressed: bytes read must stay well
9964    /// under the dictionary section. Lazy answers are compared against eager
9965    /// throughout: cheaper is only interesting if it is also correct.
9966    #[test]
9967    fn bounded_query_in_graph_does_not_fault_the_whole_dictionary() {
9968        use crate::reader::{CountingReader, SliceReader};
9969
9970        // Long, distinct, poorly-compressible object literals: the dictionary is
9971        // the payload here, exactly as on a graph that stores abstracts or
9972        // embedded media. Repeating one filler string would not do — front
9973        // coding plus zstd shrink 2000 near-identical literals to ~6 KB, which
9974        // is smaller than the index and makes the measurement meaningless.
9975        let payload = |i: u32| -> String {
9976            let mut s = String::with_capacity(800);
9977            let mut x = i as u64 ^ 0x9e37_79b9_7f4a_7c15;
9978            while s.len() < 800 {
9979                x = x
9980                    .wrapping_mul(6364136223846793005)
9981                    .wrapping_add(1442695040888963407);
9982                s.push_str(&format!("{:016x}", x ^ (x >> 29)));
9983            }
9984            s
9985        };
9986        // Half the quads go to the named graph, and the two halves are kept
9987        // lexically apart (`a…` / `b…`) so each lands in its own dictionary
9988        // chunks — the real layout on a quads file whose graphs use different
9989        // IRI namespaces, e.g. `cordis.rete`. Interleaving them instead would
9990        // put a term of both graphs in every chunk, and then no per-graph
9991        // access pattern could avoid faulting the whole dictionary.
9992        let quads: Vec<(String, String, String, bool)> = (0..2000u32)
9993            .map(|i| {
9994                let named = i % 2 == 0;
9995                let ns = if named { 'a' } else { 'b' };
9996                (
9997                    format!("<http://ex/{ns}/s/{i:04}>"),
9998                    "<http://ex/abstract>".to_string(),
9999                    format!("\"{ns}{} number {i}\"", payload(i)),
10000                    named,
10001                )
10002            })
10003            .collect();
10004        let mut db = DictionaryBuilder::new();
10005        for (s, p, o, _) in &quads {
10006            db.observe(s, p, o);
10007        }
10008        let dict = db.build();
10009        let mut def = GraphIndexBuilder::new().with_tile_budget(256);
10010        let mut g1 = GraphIndexBuilder::new().with_tile_budget(256);
10011        for (s, p, o, named) in &quads {
10012            let t = dict.encode(s, p, o).unwrap();
10013            if *named {
10014                g1.push(t);
10015            } else {
10016                def.push(t);
10017            }
10018        }
10019        let named = vec![("http://ex/g1".to_string(), g1.build())];
10020        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
10021
10022        let eager = Rete::open(&bytes).unwrap();
10023        let dict_len = eager.header().dictionary_len;
10024
10025        // `probe` runs one closure against a COLD lazy open and reports the
10026        // bytes it faulted beyond the open itself.
10027        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
10028        let probe = |f: &dyn Fn(&Rete) -> Vec<TermTriple>| -> (Vec<TermTriple>, u64) {
10029            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
10030            let lazy = Rete::open_ranged_lazy_with(reader.clone(), HEADER_LEN as u64).unwrap();
10031            let before = reader.bytes_read();
10032            let out = f(&lazy);
10033            assert!(!lazy.index_incomplete(), "a chunk fetch failed");
10034            (out, reader.bytes_read() - before)
10035        };
10036
10037        // 1. One bound subject in the named graph.
10038        let want = eager.query_in_graph(
10039            Some("http://ex/g1"),
10040            Some("<http://ex/a/s/0100>"),
10041            None,
10042            None,
10043        );
10044        assert_eq!(want.len(), 1, "fixture: expected exactly one match");
10045        let (got, pulled) = probe(&|r| {
10046            r.query_in_graph(
10047                Some("http://ex/g1"),
10048                Some("<http://ex/a/s/0100>"),
10049                None,
10050                None,
10051            )
10052        });
10053        assert_eq!(got, want, "lazy query_in_graph disagrees with eager");
10054        assert!(
10055            pulled * 4 < dict_len,
10056            "a 1-row query_in_graph faulted {pulled} B of a {dict_len} B dictionary — \
10057             it is prefetching the whole thing again"
10058        );
10059
10060        // 2. A graph with NOTHING to resolve must not fault the dictionary.
10061        //    Both shapes below used to pull every chunk before returning an
10062        //    empty answer, and `rete export` hits the second one first on a
10063        //    quads file whose default graph is empty — on `cordis.rete` that
10064        //    was 415 MB read and 2.5 GB of peak RSS to emit zero triples. What
10065        //    is left is the named-graph directory walk it takes to learn the
10066        //    graph is absent at all; that is index, not dictionary.
10067        let (empty, q_pulled) =
10068            probe(&|r| r.query_in_graph(Some("http://ex/nope"), None, None, None));
10069        assert!(empty.is_empty());
10070        let (_, d_pulled) = probe(&|r| {
10071            let mut n = 0usize;
10072            r.dump_each(Some("http://ex/nope"), |_, _, _| n += 1);
10073            assert_eq!(n, 0, "an absent graph yielded triples");
10074            Vec::new()
10075        });
10076        assert_eq!(
10077            q_pulled, d_pulled,
10078            "query_in_graph and dump_each disagree on what an absent graph costs"
10079        );
10080        assert!(
10081            q_pulled * 4 < dict_len,
10082            "an absent graph faulted {q_pulled} B of a {dict_len} B dictionary"
10083        );
10084
10085        // 3. Dumping ONE named graph costs that graph, not the whole file: the
10086        //    default graph's half of the dictionary must stay unfaulted.
10087        let want: Vec<TermTriple> = eager.dump(Some("http://ex/g1"));
10088        assert_eq!(want.len(), 1000);
10089        let (got, pulled) = probe(&|r| {
10090            let mut out = Vec::new();
10091            r.dump_each(Some("http://ex/g1"), |s, p, o| {
10092                out.push((s.to_string(), p.to_string(), o.to_string()))
10093            });
10094            out
10095        });
10096        assert_eq!(got, want, "lazy dump_each disagrees with eager dump");
10097        assert!(
10098            pulled * 3 < dict_len * 2,
10099            "dumping half the graph faulted {pulled} B of a {dict_len} B dictionary — \
10100             the other graph's chunks are being pulled in too"
10101        );
10102    }
10103
10104    /// Build a multi-chunk single-graph ranged file image for the eviction
10105    /// tests: 4000 distinct ~70-byte object literals overflow the 64 KiB
10106    /// dictionary chunk budget into several chunks, and one multi-KiB literal
10107    /// forces a large run/chunk boundary — the shape a tiny budget must survive.
10108    #[cfg(test)]
10109    fn build_multichunk_image() -> Vec<u8> {
10110        use crate::index::PermSet;
10111        let mut db = DictionaryBuilder::new();
10112        let mut triples: Vec<(String, String, String)> = (0..4000u32)
10113            .map(|i| {
10114                (
10115                    format!("<http://ex/s/{i:07}>"),
10116                    format!("<http://ex/p/{}>", i % 8),
10117                    format!("\"object literal number {i:07} with padding text to widen the term\""),
10118                )
10119            })
10120            .collect();
10121        triples.push((
10122            "<http://ex/s/big>".to_string(),
10123            "<http://ex/p/big>".to_string(),
10124            format!("\"{}\"", "Z".repeat(8000)),
10125        ));
10126        for (s, p, o) in &triples {
10127            db.observe(s, p, o);
10128        }
10129        let dict = db.build();
10130        let mut idx = GraphIndexBuilder::new()
10131            .with_tile_budget(64)
10132            .with_perms(PermSet::ALL);
10133        for (s, p, o) in &triples {
10134            idx.push(dict.encode(s, p, o).unwrap());
10135        }
10136        write_dataset(&dict, &idx.build(), &[], true, &[], 0)
10137    }
10138
10139    /// **Bounded-export phase 2: eviction is byte-transparent, and progress is
10140    /// guaranteed at any cap.** A cap far below the decompressed dictionary
10141    /// forces the chunk cache to evict during a full dump, yet the dump is
10142    /// byte-for-byte the unlimited-cap dump — the `Arc` handout keeps every
10143    /// chunk alive across its one decode, so eviction never drops bytes in use.
10144    /// A 1-byte cap (smaller than any single chunk, so every insert evicts
10145    /// itself immediately) still completes with identical output: it is the
10146    /// handout invariant, not residency, that makes a window make progress.
10147    #[test]
10148    fn tiny_dict_cache_evicts_yet_dump_is_byte_identical() {
10149        use crate::reader::SliceReader;
10150        let leaked: &'static [u8] = Box::leak(build_multichunk_image().into_boxed_slice());
10151        let dump = |cap: u64| -> (
10152            Vec<(String, String, String)>,
10153            crate::chunk_cache::CacheStats,
10154        ) {
10155            let rete =
10156                Rete::open_ranged_lazy_with(SliceReader::new(leaked), HEADER_LEN as u64).unwrap();
10157            rete.dict.set_cache_cap(cap);
10158            let mut out = Vec::new();
10159            rete.dump_filtered_each(None, None, None, None, |s, p, o| {
10160                out.push((s.to_string(), p.to_string(), o.to_string()))
10161            });
10162            assert!(
10163                !rete.index_incomplete(),
10164                "no lazy fault should fail at cap {cap}"
10165            );
10166            let stats = rete.dict_cache_stats();
10167            (out, stats)
10168        };
10169
10170        let (unlimited, us) = dump(u64::MAX);
10171        assert_eq!(unlimited.len(), 4001, "every triple dumped");
10172        assert_eq!(us.evictions, 0, "unlimited cap evicts nothing");
10173
10174        // A cap far below the decompressed dictionary: eviction happens, output
10175        // unchanged.
10176        let (tiny, ts) = dump(128 * 1024);
10177        assert_eq!(tiny, unlimited, "128 KiB-cap dump differs from unlimited");
10178        assert!(ts.evictions > 0, "a tiny cap must evict (stats {ts:?})");
10179
10180        // Progress guarantee: a 1-byte cap still completes, byte-identical.
10181        let (starved, ss) = dump(1);
10182        assert_eq!(starved, unlimited, "1-byte-cap dump differs from unlimited");
10183        assert!(
10184            ss.evictions > 0,
10185            "1-byte cap evicts every insert (stats {ss:?})"
10186        );
10187    }
10188
10189    /// **The budget pays for the open's fixed directories.** A ranged open holds
10190    /// one descriptor per dictionary chunk and per index tile for its whole
10191    /// life, outside every cache — 88 MB on the 14.75 GiB openaire-relation,
10192    /// 224 MB on epfl-infoscience. `set_memory_budget` used to give the caches
10193    /// the whole budget on top of that; it now measures the directories
10194    /// ([`Rete::fixed_overhead`]) and takes them out of the dictionary share, so
10195    /// caps + directories stay within the total. This checks the arithmetic on
10196    /// a many-chunk file, the 64 MiB floor, the unlimited pass-through, and that
10197    /// a dump under the capped budget keeps the dictionary cache within its
10198    /// (net) share.
10199    #[test]
10200    fn memory_budget_charges_the_fixed_directories() {
10201        use crate::reader::SliceReader;
10202        let leaked: &'static [u8] = Box::leak(build_multichunk_image().into_boxed_slice());
10203        let rete =
10204            Rete::open_ranged_lazy_with(SliceReader::new(leaked), HEADER_LEN as u64).unwrap();
10205
10206        let fixed = rete.fixed_overhead();
10207        // Every chunk and every tile has a descriptor; a multi-chunk dictionary
10208        // and a 64-triple tile budget make both directories non-trivial.
10209        assert!(
10210            fixed.dict_directory > 0 && fixed.index_directory > 0,
10211            "{fixed:?}"
10212        );
10213        assert_eq!(fixed.named_graphs, 0, "no named graphs in this file");
10214        assert_eq!(
10215            fixed.total(),
10216            fixed.dict_directory + fixed.index_directory + fixed.named_graphs
10217        );
10218
10219        // A budget well above the floor: the dictionary share is the plain
10220        // split less the directories, exactly.
10221        let total: u64 = 512 << 20;
10222        let applied = rete.set_memory_budget(Some(total));
10223        let plain = split_memory_budget(total);
10224        assert_eq!(applied.dict_cache, plain.dict_cache - fixed.total());
10225        assert_eq!(rete.dict_cache_cap(), applied.dict_cache);
10226        assert_eq!(applied.tile_cache, plain.tile_cache);
10227        assert_eq!(applied.block_cache, plain.block_cache);
10228        assert!(
10229            applied.dict_cache + applied.tile_cache + applied.block_cache + fixed.total() <= total,
10230            "caps + fixed directories exceed the budget: {applied:?} + {fixed:?} > {total}"
10231        );
10232
10233        // A budget whose share would fall under the floor keeps the floor.
10234        let applied = rete.set_memory_budget(Some(1 << 20));
10235        assert_eq!(applied.dict_cache, MIN_DICT_CACHE_CAP);
10236
10237        // Unlimited stays unlimited: nothing is subtracted from u64::MAX.
10238        let applied = rete.set_memory_budget(None);
10239        assert_eq!(applied.dict_cache, u64::MAX);
10240        assert_eq!(rete.dict_cache_cap(), u64::MAX);
10241
10242        // Under a cap smaller than the decompressed dictionary, a full dump
10243        // keeps the cache within it.
10244        let rete =
10245            Rete::open_ranged_lazy_with(SliceReader::new(leaked), HEADER_LEN as u64).unwrap();
10246        rete.dict.set_cache_cap(128 * 1024);
10247        let mut rows = 0usize;
10248        rete.dump_filtered_each(None, None, None, None, |_, _, _| rows += 1);
10249        assert_eq!(rows, 4001);
10250        let s = rete.dict_cache_stats();
10251        assert!(s.evictions > 0, "the cap must bind: {s:?}");
10252        // One oversized chunk may sit alone above the cap (the handout
10253        // invariant); otherwise residency is within it.
10254        assert!(
10255            s.entries <= 1 || s.resident_bytes <= 128 * 1024,
10256            "dictionary cache over its cap after the dump: {s:?}"
10257        );
10258    }
10259
10260    /// **Bounded-export phase 2: per-graph index release.** After a named
10261    /// graph's slot is exported, [`Rete::release_named_graph`] drops its decoded
10262    /// index (and its tiles) so a many-graph dump holds one graph's index at a
10263    /// time, not all of them. The graph re-opens lazily if touched again.
10264    #[test]
10265    fn releasing_a_named_graph_drops_and_reopens_its_index() {
10266        use crate::index::PermSet;
10267        use crate::reader::SliceReader;
10268        let mut db = DictionaryBuilder::new();
10269        let all: Vec<(String, String, String)> = (0..40u32)
10270            .map(|i| {
10271                (
10272                    format!("<http://ex/s{}>", i % 7),
10273                    format!("<http://ex/p{}>", i % 3),
10274                    format!("<http://ex/o{}>", i % 5),
10275                )
10276            })
10277            .collect();
10278        for (s, p, o) in &all {
10279            db.observe(s, p, o);
10280        }
10281        let dict = db.build();
10282        let mk = |take: fn(u32) -> bool| {
10283            let mut g = GraphIndexBuilder::new()
10284                .with_tile_budget(16)
10285                .with_perms(PermSet::ALL);
10286            for (i, (s, p, o)) in all.iter().enumerate() {
10287                if take(i as u32) {
10288                    g.push(dict.encode(s, p, o).unwrap());
10289                }
10290            }
10291            g.build()
10292        };
10293        let def = mk(|_| true);
10294        let g1 = mk(|i| i % 2 == 0);
10295        let g2 = mk(|i| i % 3 == 0);
10296        let image = write_dataset(
10297            &dict,
10298            &def,
10299            &[
10300                ("<http://ex/g1>".to_string(), g1),
10301                ("<http://ex/g2>".to_string(), g2),
10302            ],
10303            true,
10304            &[],
10305            0,
10306        );
10307        let leaked: &'static [u8] = Box::leak(image.into_boxed_slice());
10308        let mut rete =
10309            Rete::open_ranged_lazy_with(SliceReader::new(leaked), HEADER_LEN as u64).unwrap();
10310
10311        let opened = |r: &Rete| -> usize {
10312            match &r.named_graphs {
10313                NamedGraphsSlot::Lazy(l) => {
10314                    let mut n = 0;
10315                    l.for_each_opened(|_| n += 1);
10316                    n
10317                }
10318                _ => 0,
10319            }
10320        };
10321
10322        assert_eq!(opened(&rete), 0, "nothing opened before first touch");
10323        // Touching g1 opens its index.
10324        assert!(rete.graph_index("<http://ex/g1>").is_some());
10325        assert_eq!(opened(&rete), 1, "g1 opened");
10326        // Release it: the index is dropped.
10327        rete.release_named_graph("<http://ex/g1>");
10328        assert_eq!(opened(&rete), 0, "g1 released");
10329        // Releasing an unknown / already-released graph is a no-op.
10330        rete.release_named_graph("<http://ex/absent>");
10331        rete.release_named_graph("<http://ex/g1>");
10332        assert_eq!(opened(&rete), 0);
10333        // The graph re-opens lazily on the next access.
10334        assert!(rete.graph_index("<http://ex/g1>").is_some());
10335        assert_eq!(opened(&rete), 1, "g1 re-opened after release");
10336    }
10337
10338    /// **The filtered-dump correctness invariant** (#117 point 1): for every
10339    /// graph and every one of the eight bound/unbound shapes,
10340    /// `dump_filtered_each` must return *exactly* the quads `dump_each` returns,
10341    /// filtered — no more, and above all no fewer.
10342    ///
10343    /// "No fewer" is the whole risk. The speedup comes from *not fetching* tiles
10344    /// a synopsis says cannot match, and a pruning bug does not crash, it
10345    /// silently returns a shorter answer. So the oracle here is the unfiltered
10346    /// dump with a plain Rust `retain` over it — the thing the filter is meant
10347    /// to be a cheaper spelling of — and it is checked on every open path, since
10348    /// the eager path prunes with a different code path than the lazy one.
10349    ///
10350    /// It runs on a `PermSet::CORE` (`--permutations 3`) file as well as a
10351    /// six-permutation one. Every shape routes inside CORE — `perm_routing_never_leaves_core`
10352    /// proves the routing table does — so the two must agree row for row, and if
10353    /// a future shape ever routed outside CORE this test fails on the three-perm
10354    /// build rather than silently returning nothing from an absent section.
10355    #[test]
10356    fn filtered_dump_is_exactly_the_unfiltered_dump_filtered() {
10357        use crate::index::PermSet;
10358        use crate::reader::SliceReader;
10359
10360        let terms = |i: u32| {
10361            (
10362                format!("<http://ex/s{}>", i % 11),
10363                format!("<http://ex/p{}>", i % 3),
10364                // Mix IRIs and literals in the object column: a literal lives in
10365                // a different dictionary section than a node, and the object
10366                // filter has to resolve through the right one.
10367                if i.is_multiple_of(4) {
10368                    format!("\"lit {}\"@en", i % 5)
10369                } else {
10370                    format!("<http://ex/o{}>", i % 7)
10371                },
10372            )
10373        };
10374        let build = |perms: PermSet| {
10375            let mut db = DictionaryBuilder::new();
10376            let all: Vec<_> = (0..90u32).map(terms).collect();
10377            for (s, p, o) in &all {
10378                db.observe(s, p, o);
10379            }
10380            let dict = db.build();
10381            let mut def = GraphIndexBuilder::new()
10382                .with_tile_budget(16)
10383                .with_perms(perms);
10384            let mut g1 = GraphIndexBuilder::new()
10385                .with_tile_budget(16)
10386                .with_perms(perms);
10387            for (i, (s, p, o)) in all.iter().enumerate() {
10388                let t = dict.encode(s, p, o).unwrap();
10389                // Deliberately overlapping, not disjoint: every second triple is
10390                // in both graphs, so a filter that leaked across graphs would
10391                // still look plausible on counts alone.
10392                if i % 2 == 0 {
10393                    g1.push(t);
10394                }
10395                def.push(t);
10396            }
10397            write_dataset(
10398                &dict,
10399                &def.build(),
10400                &[("<http://ex/g1>".to_string(), g1.build())],
10401                true,
10402                &[],
10403                0,
10404            )
10405        };
10406
10407        let shapes: Vec<(Option<&str>, Option<&str>, Option<&str>)> = vec![
10408            (None, None, None),
10409            (Some("<http://ex/s3>"), None, None),
10410            (None, Some("<http://ex/p1>"), None),
10411            (None, None, Some("<http://ex/o5>")),
10412            (None, None, Some("\"lit 0\"@en")),
10413            (Some("<http://ex/s3>"), Some("<http://ex/p0>"), None),
10414            (Some("<http://ex/s4>"), None, Some("<http://ex/o4>")),
10415            (None, Some("<http://ex/p1>"), Some("<http://ex/o5>")),
10416            (
10417                Some("<http://ex/s3>"),
10418                Some("<http://ex/p0>"),
10419                Some("<http://ex/o3>"),
10420            ),
10421            // Terms the dictionary has never seen, in each position: these must
10422            // return nothing, not everything (an unresolvable bound term must
10423            // not silently degrade to "unbound").
10424            (Some("<http://ex/nope>"), None, None),
10425            (None, Some("<http://ex/nope>"), None),
10426            (None, None, Some("<http://ex/nope>")),
10427        ];
10428
10429        let mut any_pruned = false;
10430        for perms in [PermSet::ALL, PermSet::CORE] {
10431            let image = build(perms);
10432            let leaked: &'static [u8] = Box::leak(image.clone().into_boxed_slice());
10433            let opens: Vec<(&str, Rete)> = vec![
10434                ("resident", Rete::open(&image).unwrap()),
10435                (
10436                    "ranged",
10437                    Rete::open_ranged(&SliceReader::new(leaked)).unwrap(),
10438                ),
10439                (
10440                    "lazy",
10441                    Rete::open_ranged_lazy_with(SliceReader::new(leaked), HEADER_LEN as u64)
10442                        .unwrap(),
10443                ),
10444            ];
10445            for graph in [None, Some("<http://ex/g1>"), Some("<http://ex/absent>")] {
10446                // The oracle: the unfiltered dump, filtered in Rust.
10447                let full: Vec<TermTriple> = {
10448                    let mut v = Vec::new();
10449                    Rete::open(&image).unwrap().dump_each(graph, |s, p, o| {
10450                        v.push((s.to_string(), p.to_string(), o.to_string()))
10451                    });
10452                    v.sort();
10453                    v
10454                };
10455                for &(s, p, o) in &shapes {
10456                    let mut want = full.clone();
10457                    want.retain(|(ts, tp, to)| {
10458                        s.is_none_or(|x| x == ts)
10459                            && p.is_none_or(|x| x == tp)
10460                            && o.is_none_or(|x| x == to)
10461                    });
10462                    for (path, rete) in &opens {
10463                        let mut got = Vec::new();
10464                        rete.dump_filtered_each(graph, s, p, o, |s, p, o| {
10465                            got.push((s.to_string(), p.to_string(), o.to_string()))
10466                        });
10467                        got.sort();
10468                        assert_eq!(
10469                            got,
10470                            want,
10471                            "{path}/{perms:?} graph={graph:?} pattern={:?} — filtered dump \
10472                             is not the unfiltered dump filtered",
10473                            (s, p, o)
10474                        );
10475                        assert!(!rete.index_incomplete(), "{path}: a fetch failed");
10476                    }
10477                    // …and the plan must agree with what the dump actually did:
10478                    // an empty answer means either no scan at all, or a scan
10479                    // whose admitted tiles simply held nothing.
10480                    let plan = opens[2].1.dump_plan(graph, s, p, o);
10481                    if graph == Some("<http://ex/absent>")
10482                        || [s, p, o].contains(&Some("<http://ex/nope>"))
10483                    {
10484                        assert!(
10485                            plan.scan.is_none(),
10486                            "an unmatchable dump still planned a scan: {:?}",
10487                            (graph, s, p, o)
10488                        );
10489                    } else {
10490                        let scan = plan.scan.expect("a matchable dump plans a scan");
10491                        assert!(
10492                            PermSet::CORE.contains(scan.permutation),
10493                            "a dump routed to {:?}, outside PermSet::CORE — a \
10494                             --permutations 3 file has no such section",
10495                            scan.permutation
10496                        );
10497                        assert!(scan.tiles_admitted <= scan.tiles_routed);
10498                        assert!(scan.tiles_routed <= scan.tiles_total);
10499                        assert!(scan.tile_bytes <= scan.section_bytes);
10500                        if scan.tiles_admitted < scan.tiles_total {
10501                            any_pruned = true;
10502                        }
10503                    }
10504                }
10505            }
10506        }
10507        assert!(
10508            any_pruned,
10509            "no shape pruned a single tile — the fixture is too small to be \
10510             measuring anything"
10511        );
10512    }
10513
10514    /// The point of the filter: a predicate-scoped dump of a lazily range-read
10515    /// file must **fetch less** than the same graph's full dump, not merely
10516    /// return less.
10517    ///
10518    /// The fixture is the shape the win shows on — many tiles, and a predicate
10519    /// whose triples live in a minority of them — so the assertion can be about
10520    /// bytes rather than about the code path being taken on faith. Measured on
10521    /// the real thing (`cordis.rete`, 801 MB): one predicate of one named graph
10522    /// read 16.0 MB where the graph's full dump read 375.8 MB.
10523    #[test]
10524    fn a_predicate_scoped_dump_fetches_less_than_the_graph() {
10525        use crate::reader::{CountingReader, SliceReader};
10526
10527        // 200 subjects × 20 predicates, tiled small so there are many tiles, and
10528        // one distinct object per cell so that a predicate slice needs a
10529        // twentieth of the object dictionary rather than all of it — otherwise
10530        // the dictionary, which this change does not prune, swamps the index
10531        // saving the test is about.
10532        let mut db = DictionaryBuilder::new();
10533        let mut all = Vec::new();
10534        for s in 0..200u32 {
10535            for p in 0..20u32 {
10536                let t = (
10537                    format!("<http://ex/s/{s:04}>"),
10538                    format!("<http://ex/p/{p:02}>"),
10539                    format!("\"object {s:04}/{p:02}\""),
10540                );
10541                db.observe(&t.0, &t.1, &t.2);
10542                all.push(t);
10543            }
10544        }
10545        let dict = db.build();
10546        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
10547        for (s, p, o) in &all {
10548            ib.push(dict.encode(s, p, o).unwrap());
10549        }
10550        let image = write_dataset(&dict, &ib.build(), &[], false, &[], 0);
10551        let leaked: &'static [u8] = Box::leak(image.clone().into_boxed_slice());
10552
10553        // Bytes faulted past the open, for one dump.
10554        let probe = |s: Option<&str>, p: Option<&str>, o: Option<&str>| -> (usize, u64) {
10555            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
10556            let lazy = Rete::open_ranged_lazy_with(reader.clone(), HEADER_LEN as u64).unwrap();
10557            let before = reader.bytes_read();
10558            let mut n = 0usize;
10559            lazy.dump_filtered_each(None, s, p, o, |_, _, _| n += 1);
10560            assert!(!lazy.index_incomplete());
10561            (n, reader.bytes_read() - before)
10562        };
10563
10564        let (full_rows, full_bytes) = probe(None, None, None);
10565        let (pred_rows, pred_bytes) = probe(None, Some("<http://ex/p/07>"), None);
10566        let (subj_rows, subj_bytes) = probe(Some("<http://ex/s/0021>"), None, None);
10567        assert_eq!(full_rows, 4000);
10568        assert_eq!(pred_rows, 200, "one predicate covers every subject once");
10569        assert_eq!(subj_rows, 20);
10570        // True in any build: a slice fetches strictly less than the graph.
10571        assert!(
10572            pred_bytes < full_bytes && subj_bytes < full_bytes,
10573            "a slice fetched {pred_bytes} / {subj_bytes} B where the whole graph fetched \
10574             {full_bytes} B — the scan is not being pruned at all"
10575        );
10576        // The SIZE of the win is a property of the FILE, not of this change: it is
10577        // the index share of what a dump reads, and the dictionary — which no
10578        // filter prunes — is the rest. With the default `compression` feature the
10579        // dictionary chunk bodies are zstd-compressed and the slice costs about a
10580        // tenth of the graph (measured here: 1,565 B for the predicate and 864 B
10581        // for the subject, against 15,714 B). Built `--no-default-features` those
10582        // same chunks are stored RAW, so resolving 200 rows' terms dominates and
10583        // the identical, identically-pruned scan costs 22,225 B of 40,165 — 55%.
10584        // Asserting the strong ratio unconditionally would be asserting that zstd
10585        // is enabled; the index-side prune is checked below and holds either way.
10586        #[cfg(feature = "compression")]
10587        assert!(
10588            pred_bytes * 4 < full_bytes && subj_bytes * 4 < full_bytes,
10589            "a 1-in-20 predicate slice fetched {pred_bytes} B and one subject \
10590             {subj_bytes} B where the whole graph fetched {full_bytes} B"
10591        );
10592
10593        // And the plan predicted the index side of it without fetching a tile.
10594        let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
10595        let lazy = Rete::open_ranged_lazy_with(reader.clone(), HEADER_LEN as u64).unwrap();
10596        let before = reader.bytes_read();
10597        let plan = lazy
10598            .dump_plan(None, None, Some("<http://ex/p/07>"), None)
10599            .scan
10600            .expect("a known predicate plans a scan");
10601        assert!(
10602            reader.bytes_read() - before < 64 * 1024,
10603            "planning fetched {} B — it is supposed to read directories, not tiles",
10604            reader.bytes_read() - before
10605        );
10606        assert!(plan.tiles_admitted < plan.tiles_total);
10607        // The index side on its own, with the dictionary's share taken out: one
10608        // predicate of twenty routes to a handful of POS tiles. (Measured here:
10609        // the whole dump faults 15,714 B, the predicate slice 1,565 B and one
10610        // subject 864 B — a 10x and an 18x, most of what is left being the
10611        // dictionary this change does not prune.)
10612        assert!(
10613            plan.tile_bytes * 5 < plan.section_bytes,
10614            "one predicate of twenty planned {} B of a {} B section",
10615            plan.tile_bytes,
10616            plan.section_bytes
10617        );
10618    }
10619
10620    #[test]
10621    fn query_quads_tags_every_graph() {
10622        let mut db = DictionaryBuilder::new();
10623        for (s, p, o) in [("Alice", "knows", "Bob"), ("Alice", "knows", "Dave")] {
10624            db.observe(s, p, o);
10625        }
10626        let dict = db.build();
10627        let mut def = GraphIndexBuilder::new();
10628        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
10629        let mut g1 = GraphIndexBuilder::new();
10630        g1.push(dict.encode("Alice", "knows", "Dave").unwrap());
10631        let named = vec![("http://ex/g1".to_string(), g1.build())];
10632        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
10633        let rete = Rete::open(&bytes).unwrap();
10634
10635        // `Alice knows ?` spans both graphs; each match carries its graph tag.
10636        let quads = rete.query_quads(Some("Alice"), Some("knows"), None);
10637        assert_eq!(quads.len(), 2);
10638        assert_eq!(
10639            quads[0],
10640            (("Alice".into(), "knows".into(), "Bob".into()), None)
10641        );
10642        assert_eq!(
10643            quads[1],
10644            (
10645                ("Alice".into(), "knows".into(), "Dave".into()),
10646                Some("http://ex/g1".to_string())
10647            )
10648        );
10649
10650        // A bound term absent from the dictionary yields nothing, in any graph.
10651        assert!(rete.query_quads(Some("Nobody"), None, None).is_empty());
10652    }
10653
10654    #[test]
10655    fn pyramid_meta_round_trips_in_file() {
10656        let rete = Rete::open(&build_image()).unwrap();
10657        let pyr = rete.pyramid().expect("file has a pyramid");
10658        // Summary covers all 3 triples by count; tiles are not stored in v0.
10659        let total: u32 = pyr.summary.iter().map(|e| e.count).sum();
10660        assert_eq!(total, 3);
10661        assert!(!pyr.summary.is_empty());
10662        assert!(pyr.tiles.is_empty());
10663    }
10664
10665    #[test]
10666    fn schema_pyramid_round_trips_through_file_index_free() {
10667        use crate::reader::{CountingReader, SliceReader};
10668        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
10669        let q = |s: &str, p: &str, o: &str| {
10670            (s.to_string(), p.to_string(), o.to_string(), None::<String>)
10671        };
10672        // Astronomer ⊑ Scientist ⊑ Person ⊑ Agent, instances at the leaves.
10673        let quads = vec![
10674            q("<a>", RDF_TYPE, "<Astronomer>"),
10675            q("<b>", RDF_TYPE, "<Astronomer>"),
10676            q("<c>", RDF_TYPE, "<Person>"),
10677            q("<Astronomer>", sub, "<Scientist>"),
10678            q("<Scientist>", sub, "<Person>"),
10679            q("<Person>", sub, "<Agent>"),
10680            q("<a>", "<knows>", "<b>"),
10681            q("<b>", "<knows>", "<c>"),
10682        ];
10683        let (bytes, _) =
10684            crate::ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| Vec::new());
10685
10686        // The v2 schema pyramid round-trips through the built file.
10687        let rete = Rete::open(&bytes).unwrap();
10688        let pyr = rete.pyramid().expect("pyramid present");
10689        assert!(!pyr.level_rollups.is_empty(), "schema pyramid shipped");
10690        assert!(pyr
10691            .class_hierarchy
10692            .iter()
10693            .any(|n| n.class == "<Agent>" && n.depth == 0));
10694
10695        // It reads index-free: a SummaryView open never touches the index section.
10696        let r = CountingReader::new(SliceReader::new(&bytes));
10697        let view = SummaryView::open_ranged(&r).unwrap().unwrap();
10698        assert!(view.level_count() >= 2, "multi-level pyramid");
10699        let coarse = view.level_rollup(0).unwrap();
10700        assert!(
10701            coarse.classes.iter().any(|(c, _)| c == "<Agent>"),
10702            "coarsest level rolls up to the root Agent"
10703        );
10704        let h = Header::from_bytes(&bytes).unwrap();
10705        assert!(
10706            r.bytes_read() <= bytes.len() as u64 - h.root_dir_len,
10707            "summary read {} bytes; the {}-byte index section must be skipped",
10708            r.bytes_read(),
10709            h.root_dir_len
10710        );
10711    }
10712
10713    #[test]
10714    fn predicate_totals_from_summary_only() {
10715        use crate::reader::SliceReader;
10716        // build_image: 2 `knows` triples + 1 `age` triple.
10717        let bytes = build_image();
10718        let reader = SliceReader::new(&bytes);
10719        let view = SummaryView::open_ranged(&reader).unwrap().unwrap();
10720        assert_eq!(view.predicate_total("knows"), 2);
10721        assert_eq!(view.predicate_total("age"), 1);
10722        assert_eq!(view.predicate_total("missing"), 0);
10723        let totals = view.predicate_totals();
10724        assert_eq!(totals[0], ("knows".to_string(), 2)); // sorted by count desc
10725    }
10726
10727    #[test]
10728    fn query_patterns_resolve_to_terms() {
10729        let rete = Rete::open(&build_image()).unwrap();
10730
10731        // All triples.
10732        assert_eq!(rete.query(None, None, None).len(), 3);
10733
10734        // Subject bound.
10735        let mut alice = rete.query(Some("Alice"), None, None);
10736        alice.sort();
10737        assert_eq!(
10738            alice,
10739            vec![
10740                ("Alice".into(), "age".into(), "30".into()),
10741                ("Alice".into(), "knows".into(), "Bob".into()),
10742            ]
10743        );
10744
10745        // Predicate bound.
10746        assert_eq!(rete.query(None, Some("knows"), None).len(), 2);
10747
10748        // Full triple, present and absent.
10749        assert_eq!(
10750            rete.query(Some("Bob"), Some("knows"), Some("Carol")),
10751            vec![("Bob".into(), "knows".into(), "Carol".into())]
10752        );
10753        assert!(rete.query(Some("Nobody"), None, None).is_empty());
10754        assert!(rete.query(None, Some("likes"), None).is_empty());
10755    }
10756
10757    #[test]
10758    fn query_provenance_reports_terms_ids_sections_and_index_choice() {
10759        let bytes = build_image();
10760        let rete = Rete::open(&bytes).unwrap();
10761
10762        let mut matches = rete.query_with_provenance(None, Some("knows"), None);
10763        matches.sort_by(|a, b| a.terms.cmp(&b.terms));
10764
10765        assert_eq!(matches.len(), 2);
10766        assert_eq!(
10767            matches[0].terms,
10768            ("Alice".into(), "knows".into(), "Bob".into())
10769        );
10770        assert_eq!(
10771            matches[0].ids,
10772            rete.dictionary().encode("Alice", "knows", "Bob").unwrap()
10773        );
10774        assert_eq!(matches[0].graph.as_deref(), None);
10775        assert_eq!(
10776            matches[0].matched_pattern,
10777            (None, Some(matches[0].ids.1), None)
10778        );
10779        assert_eq!(
10780            matches[0].index_permutation,
10781            crate::index::IndexPermutation::Pos
10782        );
10783
10784        let h = rete.header();
10785        assert_eq!(matches[0].dictionary_range.offset, h.dictionary_offset);
10786        assert_eq!(matches[0].dictionary_range.len, h.dictionary_len);
10787        assert_eq!(matches[0].index_range.offset, h.root_dir_offset);
10788        assert_eq!(matches[0].index_range.len, h.root_dir_len);
10789        assert!(
10790            matches[0].index_section_range.offset > h.root_dir_offset,
10791            "POS is section 1, so its payload starts after the container header and SPO payload"
10792        );
10793        assert!(matches[0].index_section_range.len > 0);
10794        assert!(matches[0].index_section_range.end() <= matches[0].index_range.end());
10795        assert!(matches[0].index_section_range.len < matches[0].index_range.len);
10796        assert_eq!(
10797            matches[0].pyramid_range.as_ref().map(|r| (r.offset, r.len)),
10798            Some((h.pyramid_meta_offset, h.pyramid_meta_len))
10799        );
10800        // Tiled (v0.2) files report the physical tile holding the match; its
10801        // compressed byte range nests inside the selected section payload.
10802        let tile_range = matches[0].tile_range.expect("tiled file reports a tile");
10803        assert!(matches[0]
10804            .tile
10805            .as_deref()
10806            .unwrap()
10807            .starts_with(matches[0].index_permutation.name()));
10808        assert!(matches[0].index_section_range.offset <= tile_range.offset);
10809        assert!(tile_range.end() <= matches[0].index_section_range.end());
10810    }
10811
10812    /// Build an in-memory `.rete` with `n` labeled subjects: each carries an
10813    /// `rdfs:label` literal drawn from `WORDS` (a word prefix selects ~1/|WORDS|
10814    /// of them) plus one extra edge so the subject has a degree to rank by.
10815    fn build_labeled(n: usize) -> Vec<u8> {
10816        const LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
10817        const WORDS: &[&str] = &[
10818            "alanine",
10819            "benzene",
10820            "glucose",
10821            "dextrose",
10822            "ethanol",
10823            "formate",
10824            "heptane",
10825            "isoleucine",
10826        ];
10827        let triples: Vec<(String, String, String)> = (0..n)
10828            .flat_map(|i| {
10829                let s = format!("<http://ex/e{i}>");
10830                let w = WORDS[i % WORDS.len()];
10831                [
10832                    (s.clone(), LABEL.to_string(), format!("\"{w}-{i:06}\"")),
10833                    (
10834                        s,
10835                        "<http://ex/p>".to_string(),
10836                        format!("<http://ex/c{}>", i % 64),
10837                    ),
10838                ]
10839            })
10840            .collect();
10841        let mut db = DictionaryBuilder::new();
10842        for (s, p, o) in &triples {
10843            db.observe(s, p, o);
10844        }
10845        let dict = db.build();
10846        let ids: Vec<(u32, u32, u32)> = triples
10847            .iter()
10848            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
10849            .collect();
10850        let mut ib = GraphIndexBuilder::new();
10851        for &t in &ids {
10852            ib.push(t);
10853        }
10854        let (meta, levels) = build_pyramid_meta(&dict, &ids, DEFAULT_TILE_BUDGET);
10855        write_file(&dict, &ib.build(), false, &meta, levels)
10856    }
10857
10858    #[test]
10859    fn prefix_search_matches_a_filter_scan() {
10860        // 800 < the 8192 label-index cap, so the index is COMPLETE — every label
10861        // is present and the two paths must return the exact same subject set.
10862        let bytes = build_labeled(800);
10863        let rete = Rete::open(&bytes).unwrap();
10864        let idx_subjects: std::collections::BTreeSet<String> = rete
10865            .prefix_search("glucose", 10_000)
10866            .into_iter()
10867            .map(|(_label, subject)| subject)
10868            .collect();
10869        // The same selection via a SPARQL FILTER scan over every label literal.
10870        let q = "SELECT ?s WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?l \
10871                 FILTER(STRSTARTS(LCASE(?l), \"glucose\")) }";
10872        let crate::QueryOutput::Select(_, rows) = crate::eval_query(&rete, q).unwrap() else {
10873            panic!("expected SELECT");
10874        };
10875        let scan_subjects: std::collections::BTreeSet<String> =
10876            rows.iter().map(|r| r.get("s").cloned().unwrap()).collect();
10877        assert_eq!(idx_subjects, scan_subjects, "index agrees with the scan");
10878        assert_eq!(
10879            idx_subjects.len(),
10880            100,
10881            "800/8 words = 100 glucose-* labels"
10882        );
10883    }
10884
10885    /// A legacy (pyramid-block) label search resolves its hits' subjects in one
10886    /// batched dictionary read on a lazy open — the label-search twin of the
10887    /// full-text hits' batch — instead of one dependent read per chunk.
10888    #[test]
10889    fn legacy_label_hits_resolve_in_one_batched_read() {
10890        use std::sync::{Arc, Mutex};
10891        type CallLog = Arc<Mutex<Vec<Vec<(u64, u64)>>>>;
10892        #[derive(Clone)]
10893        struct Calls(Arc<Vec<u8>>, CallLog);
10894        impl crate::RangeReader for Calls {
10895            fn len(&self) -> u64 {
10896                self.0.len() as u64
10897            }
10898            fn read_at(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
10899                self.1.lock().unwrap().push(vec![(offset, len)]);
10900                crate::SliceReader::new(&self.0).read_at(offset, len)
10901            }
10902            fn read_many(&self, ranges: &[(u64, u64)]) -> std::io::Result<Vec<Vec<u8>>> {
10903                self.1.lock().unwrap().push(ranges.to_vec());
10904                let r = crate::SliceReader::new(&self.0);
10905                ranges.iter().map(|&(o, l)| r.read_at(o, l)).collect()
10906            }
10907        }
10908        let bytes = build_labeled(20_000);
10909        let h = Header::from_bytes(&bytes).unwrap();
10910        let (d0, dl) = (h.dictionary_offset, h.dictionary_len);
10911        let want = Rete::open(&bytes).unwrap().prefix_search("glucose", 1000);
10912        assert_eq!(want.len(), 1000);
10913        for search_view in [false, true] {
10914            let calls = Calls(Arc::new(bytes.clone()), Arc::new(Mutex::new(Vec::new())));
10915            let got = if search_view {
10916                let view = SearchView::open_ranged_with(calls.clone(), HEADER_LEN as u64).unwrap();
10917                calls.1.lock().unwrap().clear();
10918                view.prefix_search("glucose", 1000)
10919            } else {
10920                let rete = Rete::open_ranged_lazy_with(calls.clone(), HEADER_LEN as u64).unwrap();
10921                calls.1.lock().unwrap().clear();
10922                rete.prefix_search("glucose", 1000)
10923            };
10924            assert_eq!(got, want, "search view = {search_view}");
10925            let dict_reads = calls
10926                .1
10927                .lock()
10928                .unwrap()
10929                .iter()
10930                .filter(|c| c.iter().any(|&(o, l)| o < d0 + dl && d0 < o + l))
10931                .count();
10932            assert_eq!(dict_reads, 1, "search view = {search_view}");
10933        }
10934    }
10935
10936    /// Latency: the binary-search label index vs the FILTER scan it replaces.
10937    /// Ignored by default (timing-sensitive); run with
10938    /// `cargo test -p rete-core -- --ignored --nocapture bench_prefix_search`.
10939    #[test]
10940    #[ignore]
10941    fn bench_prefix_search_vs_filter_scan() {
10942        use std::time::Instant;
10943        let n = 6000; // < the 8192 cap, so both paths return identical sets
10944        let bytes = build_labeled(n);
10945        let rete = Rete::open(&bytes).unwrap();
10946        let q = "SELECT ?s WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?l \
10947                 FILTER(STRSTARTS(LCASE(?l), \"glucose\")) }";
10948        let reps = 200;
10949        let idx_n = rete.prefix_search("glucose", 100_000).len();
10950        let t = Instant::now();
10951        for _ in 0..reps {
10952            std::hint::black_box(rete.prefix_search("glucose", 100_000));
10953        }
10954        let idx_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
10955        let t = Instant::now();
10956        for _ in 0..reps {
10957            let _ = std::hint::black_box(crate::eval_query(&rete, q).unwrap());
10958        }
10959        let scan_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
10960        println!(
10961            "label prefix search over {n} labeled subjects ({idx_n} matches): \
10962             index {idx_ms:.4} ms vs FILTER scan {scan_ms:.3} ms ({:.0}× faster)",
10963            scan_ms / idx_ms
10964        );
10965    }
10966
10967    /// Latency: the TEXT_INDEX word search vs the `FILTER(CONTAINS(?l, …))` scan
10968    /// it replaces. Ignored by default (timing-sensitive); run with
10969    /// `cargo test -p rete-core -- --ignored --nocapture bench_text_search`.
10970    #[test]
10971    #[ignore]
10972    fn bench_text_search_vs_contains_scan() {
10973        use std::time::Instant;
10974        const LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
10975        const WORDS: &[&str] = &[
10976            "alanine",
10977            "benzene",
10978            "glucose",
10979            "dextrose",
10980            "ethanol",
10981            "formate",
10982            "heptane",
10983            "isoleucine",
10984        ];
10985        let n = 6000;
10986        let triples: Vec<(String, String, String)> = (0..n)
10987            .map(|i| {
10988                (
10989                    format!("<http://ex/e{i}>"),
10990                    LABEL.to_string(),
10991                    format!("\"{} sample number {i:06}\"", WORDS[i % WORDS.len()]),
10992                )
10993            })
10994            .collect();
10995        let mut db = DictionaryBuilder::new();
10996        for (s, p, o) in &triples {
10997            db.observe(s, p, o);
10998        }
10999        let dict = db.build();
11000        let ids: Vec<(u32, u32, u32)> = triples
11001            .iter()
11002            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
11003            .collect();
11004        let mut ib = GraphIndexBuilder::new();
11005        for &t in &ids {
11006            ib.push(t);
11007        }
11008        let ti = compute_text_index(&dict, &ids);
11009        let bytes = write_dataset_with_metadata(&dict, &ib.build(), &[], false, &[], 0, &[], &ti);
11010        let rete = Rete::open(&bytes).unwrap();
11011
11012        let q = "SELECT ?s WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?l \
11013                 FILTER(CONTAINS(LCASE(?l), \"glucose\")) }";
11014        let reps = 200;
11015        let idx_n = rete.text_search(&["glucose"], None, 100_000).len();
11016        let t = Instant::now();
11017        for _ in 0..reps {
11018            std::hint::black_box(rete.text_search(&["glucose"], None, 100_000));
11019        }
11020        let idx_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
11021        let t = Instant::now();
11022        for _ in 0..reps {
11023            let _ = std::hint::black_box(crate::eval_query(&rete, q).unwrap());
11024        }
11025        let scan_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
11026        println!(
11027            "text search over {n} literals ({idx_n} matches): \
11028             index {idx_ms:.4} ms vs FILTER(CONTAINS) scan {scan_ms:.3} ms ({:.0}× faster)",
11029            scan_ms / idx_ms
11030        );
11031    }
11032
11033    /// Operational debugging harness for a REAL on-disk file (ignored; driven
11034    /// by env vars): step-by-step dump of a bound (p, o) POS routing.
11035    ///   RETE_DEBUG_FILE=<path.rete> RETE_DEBUG_P=<iri> RETE_DEBUG_O=<iri>
11036    #[test]
11037    #[ignore = "operational tool, driven by RETE_DEBUG_* env vars"]
11038    fn debug_bound_po_routing() {
11039        struct FR(std::fs::File);
11040        impl crate::RangeReader for FR {
11041            fn len(&self) -> u64 {
11042                self.0.metadata().map(|m| m.len()).unwrap_or(0)
11043            }
11044            fn read_at(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
11045                use std::os::unix::fs::FileExt;
11046                let mut buf = vec![0u8; len as usize];
11047                self.0.read_exact_at(&mut buf, offset)?;
11048                Ok(buf)
11049            }
11050        }
11051        let path = std::env::var("RETE_DEBUG_FILE").expect("RETE_DEBUG_FILE");
11052        let p_iri = std::env::var("RETE_DEBUG_P").expect("RETE_DEBUG_P");
11053        let o_iri = std::env::var("RETE_DEBUG_O").expect("RETE_DEBUG_O");
11054        let rete = Rete::open_ranged_lazy_with(
11055            std::sync::Arc::new(FR(std::fs::File::open(&path).unwrap())),
11056            HEADER_LEN as u64,
11057        )
11058        .unwrap();
11059        let pid = rete.dict.predicate_id(&p_iri).expect("p resolves");
11060        let oid = rete.dict.object_id(&o_iri).expect("o resolves");
11061        eprintln!("pid={pid} oid={oid}");
11062        let pattern = (None, Some(pid), Some(oid));
11063        let perm = GraphIndex::best_permutation(pattern);
11064        eprintln!("best_permutation = {}", perm.name());
11065        let si = perm.section_index();
11066        let tiles = &rete.index.sections[si];
11067        eprintln!("section {} tiles = {}", perm.name(), tiles.len());
11068        let [pa, pb, pc] = perm.order_pattern(pattern);
11069        eprintln!("permuted pattern pa={pa:?} pb={pb:?} pc={pc:?}");
11070        let (start, end) = rete.index.tile_span(si, pa);
11071        eprintln!("tile_span = [{start}, {end}) -> {} tiles", end - start);
11072        let mut admitted = 0usize;
11073        for (ti, t) in tiles.iter().enumerate().take(end).skip(start) {
11074            if t.syn_admits(pb, pc) {
11075                admitted += 1;
11076                if admitted <= 10 {
11077                    let (lo, hi) = t.leading_range();
11078                    eprintln!("  admit tile {ti}: a=[{lo},{hi}] syn={:?}", t.syn);
11079                }
11080            }
11081        }
11082        eprintln!("admitted {admitted} tile(s) by synopsis");
11083        let n = rete.index.scan_iter(pattern).count();
11084        eprintln!("scan_iter matches = {n}");
11085        let hi_res = rete.query(None, Some(&p_iri), Some(&o_iri));
11086        eprintln!("high-level query matches = {}", hi_res.len());
11087    }
11088
11089    /// The header segment's LABEL and the span it describes must agree. They
11090    /// did not from v0.3 (which grew the header to 1 KiB) until this test: the
11091    /// label still said "fixed 128 bytes", the pre-v0.3 size, while the segment
11092    /// covered `HEADER_LEN` = 1024 — and that label is exactly what the file
11093    /// explorer shows someone inspecting a real file. Reading the size back out
11094    /// of the string is the point: it fails if anyone retypes the number
11095    /// instead of deriving it from `HEADER_LEN`.
11096    #[test]
11097    fn layout_header_label_matches_its_span() {
11098        let mut db = DictionaryBuilder::new();
11099        let mut triples = Vec::new();
11100        for i in 0..24u32 {
11101            let (s, p, o) = (
11102                format!("<http://ex/s{}>", i % 5),
11103                format!("<http://ex/p{}>", i % 3),
11104                format!("<http://ex/o{i}>"),
11105            );
11106            db.observe(&s, &p, &o);
11107            triples.push((s, p, o));
11108        }
11109        let dict = db.build();
11110        let ids: Vec<(u32, u32, u32)> = triples
11111            .iter()
11112            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
11113            .collect();
11114        let index = GraphIndexBuilder::from_triples(ids).build();
11115        let image = write_dataset_with_metadata(
11116            &dict,
11117            &index,
11118            &[],
11119            false,
11120            &[],
11121            0,
11122            br#"{"name":"layout-label-fixture"}"#,
11123            &[],
11124        );
11125        let rete = Rete::open(&image).unwrap();
11126
11127        let layout = rete.file_layout();
11128        let header = layout
11129            .iter()
11130            .find(|s| s.kind == "header")
11131            .expect("every layout starts with a header segment");
11132
11133        assert_eq!(
11134            header.offset, 0,
11135            "the header is the first thing in the file"
11136        );
11137        assert_eq!(
11138            header.len, HEADER_LEN as u64,
11139            "the header segment must span exactly HEADER_LEN"
11140        );
11141
11142        // Pull the number back out of the human-readable label and hold it to
11143        // the span. A hard-coded literal that drifts from HEADER_LEN dies here.
11144        let digits: String = header
11145            .label
11146            .chars()
11147            .skip_while(|c| !c.is_ascii_digit())
11148            .take_while(|c| c.is_ascii_digit())
11149            .collect();
11150        assert!(
11151            !digits.is_empty(),
11152            "header label {:?} states no size at all",
11153            header.label
11154        );
11155        assert_eq!(
11156            digits.parse::<u64>().unwrap(),
11157            header.len,
11158            "header label {:?} disagrees with the {}-byte span it describes",
11159            header.label,
11160            header.len
11161        );
11162
11163        // The layout must also stay sorted and start at byte zero, which is
11164        // what makes "the first segment is the header" meaningful.
11165        assert!(
11166            layout.windows(2).all(|w| w[0].offset <= w[1].offset),
11167            "file_layout must return segments sorted by offset"
11168        );
11169    }
11170}