Skip to main content

rete_core/
file.rs

1//! `.rete` file assembly and reading (SPEC.md §4, §9).
2//!
3//! v0 layout:
4//!
5//! ```text
6//! [0..1024)  header ([`crate::header::HEADER_LEN`])
7//! [dict]     dictionary container: 4 front-coded sections
8//! [index]    permutation container: 6 triple blocks (SPO/POS/OSP/SOP/PSO/OPS)
9//! [pyramid]  summary meta (and, in future, tile directories)
10//! [footer]   trailing magic
11//! ```
12//!
13//! The header points at the dictionary container (`dictionary_offset/len`) and
14//! the permutation container (`root_dir_offset/len`); routed readers can fetch a
15//! single permutation payload from that container.
16
17use crate::dictionary::Dictionary;
18use crate::header::{
19    Header, Section, SectionKind, FLAG_HAS_QUADS, FLAG_HAS_QUOTED_TRIPLES, FLAG_TILE_SYNOPSIS,
20    HEADER_LEN, MAGIC,
21};
22use crate::index::{GraphIndex, IndexPermutation, Pattern, PermSet, NUM_PERMS};
23use crate::meta::{ClassNode, CommunityDescriptor, LevelLinks, LevelRollup, PyramidMeta};
24use crate::pyramid::{build_dendrogram, project_graph, PyramidAlgo};
25use crate::reader::RangeReader;
26use crate::tiling::{choose_round_for_budget, summarize, SuperEdge};
27use crate::triples::Triple;
28use crate::varint::{read_uvarint, write_uvarint};
29
30/// Default per-tile byte budget `T` (SPEC.md §7.1).
31pub const DEFAULT_TILE_BUDGET: usize = 64 * 1024;
32
33/// Build the encoded pyramid-meta section for a graph: cluster, pick a round
34/// sized to `budget`, then emit the **summary** (quotient) graph. Returns
35/// `(encoded_meta, pyramid_levels)`.
36///
37/// Per-community tiles are *not* stored: they would duplicate every triple, and
38/// the exact ranged single-pattern path now routes into one permutation section
39/// without that fourth copy. Physical community-tile directories are the next
40/// storage step (SPEC §7.2).
41pub fn build_pyramid_meta(
42    dict: &Dictionary,
43    triples: &[(u32, u32, u32)],
44    budget: usize,
45) -> (Vec<u8>, u16) {
46    build_pyramid_meta_with(dict, triples, budget, None)
47}
48
49/// Like [`build_pyramid_meta`], but `type_override` forces the schema-pyramid's
50/// type predicate (e.g. `wdt:P31`) instead of auto-detection. Uses the default
51/// [`PyramidAlgo::Louvain`] community algorithm — byte-identical to before.
52pub fn build_pyramid_meta_with(
53    dict: &Dictionary,
54    triples: &[(u32, u32, u32)],
55    budget: usize,
56    type_override: Option<&str>,
57) -> (Vec<u8>, u16) {
58    build_pyramid_meta_algo(dict, triples, budget, type_override, PyramidAlgo::Louvain)
59}
60
61/// Like [`build_pyramid_meta_with`], but selects the community [`PyramidAlgo`].
62/// [`PyramidAlgo::Types`] partitions by `rdf:type` — the deterministic,
63/// parallelizable alternative to Louvain (one linear pass, no modularity) that
64/// still emits the full summary + `query_stats`; it falls back to Louvain when the
65/// graph has no usable typing. Everything downstream of the dendrogram (round
66/// choice, summary, schema pyramid, planner stats) is shared across algorithms.
67pub fn build_pyramid_meta_algo(
68    dict: &Dictionary,
69    triples: &[(u32, u32, u32)],
70    budget: usize,
71    type_override: Option<&str>,
72    algo: PyramidAlgo,
73) -> (Vec<u8>, u16) {
74    build_pyramid_meta_layout(
75        dict,
76        triples,
77        budget,
78        type_override,
79        algo,
80        PyramidLayout::Combined,
81    )
82}
83
84/// What a pyramid-meta section carries besides the community summary.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
86pub enum PyramidLayout {
87    /// Everything, as every build up to 0.3.x wrote it: the summary, the
88    /// per-community descriptors, the schema pyramid, `query_stats`, the
89    /// characteristic sets and the bounded (8,192-entry) label block.
90    #[default]
91    Combined,
92    /// Only what needs the community partition: the summary and the
93    /// per-community descriptors. The schema, `query_stats` and the
94    /// characteristic sets live in the SCHEMA section (kind 9) and the labels
95    /// in the LABEL_INDEX section (kind 8) — what `rete build` writes.
96    CommunityOnly,
97}
98
99/// Like [`build_pyramid_meta_algo`], choosing the [`PyramidLayout`].
100pub fn build_pyramid_meta_layout(
101    dict: &Dictionary,
102    triples: &[(u32, u32, u32)],
103    budget: usize,
104    type_override: Option<&str>,
105    algo: PyramidAlgo,
106    layout: PyramidLayout,
107) -> (Vec<u8>, u16) {
108    // Optional sub-phase timing (set RETE_BUILD_TIMING=1) — the pyramid build is
109    // the dominant cost of a big `rete build`; this shows where inside it.
110    // `Instant::now()` must stay behind the flag: `std::time` is unsupported on
111    // `wasm32-unknown-unknown` and panics ("time not implemented"), so an
112    // unconditional clock read would break every in-browser `build()`.
113    let timing = std::env::var_os("RETE_BUILD_TIMING").is_some();
114    let mut t = timing.then(std::time::Instant::now);
115    let mut lap = |label: &str| {
116        if let Some(t0) = &mut t {
117            eprintln!(
118                "  [pyramid] {label}: {:.0} ms",
119                t0.elapsed().as_secs_f64() * 1000.0
120            );
121            *t0 = std::time::Instant::now();
122        }
123    };
124
125    // The community partition — the only step that differs by algorithm.
126    let louvain = |lap: &mut dyn FnMut(&str)| {
127        let g = project_graph(dict, triples);
128        lap("project_graph");
129        let d = build_dendrogram(&g);
130        lap("build_dendrogram (Louvain)");
131        d
132    };
133    let dend = match algo {
134        PyramidAlgo::Louvain => louvain(&mut lap),
135        PyramidAlgo::Types => {
136            match crate::schema_pyramid::build_type_dendrogram(dict, triples, type_override) {
137                Some(d) => {
138                    lap("build_type_dendrogram");
139                    d
140                }
141                None => {
142                    eprintln!(
143                        "  [pyramid] --pyramid-algo types: no usable rdf:type \
144                         predicate — falling back to louvain"
145                    );
146                    louvain(&mut lap)
147                }
148            }
149        }
150    };
151    let round = choose_round_for_budget(dict, triples, &dend, budget);
152    lap("choose_round_for_budget");
153    let summary = summarize(dict, triples, &dend, round);
154    lap("summarize");
155    // Attach the v2 schema pyramid (the non-exclusive subClassOf DAG + per-level
156    // type rollups + per-level lateral class relations + per-community
157    // descriptors). Empty when the graph has no usable typing, in which case the
158    // encoding stays byte-identical to a v1 pyramid-meta.
159    if layout == PyramidLayout::CommunityOnly {
160        // Descriptors only: the schema pyramid's other fields, the planner
161        // stats, the shapes and the labels have sections of their own.
162        let descriptors = crate::schema_pyramid::build_community_descriptors(
163            dict,
164            triples,
165            &dend,
166            round,
167            type_override,
168        );
169        lap("build_community_descriptors");
170        let meta = PyramidMeta::new(round as u32, summary, &[]).with_schema(
171            Vec::new(),
172            Vec::new(),
173            Vec::new(),
174            descriptors,
175            Vec::new(),
176            Vec::new(),
177            Vec::new(),
178        );
179        let out = (meta.encode(), dend.rounds() as u16);
180        lap("encode");
181        return out;
182    }
183    let sp = crate::schema_pyramid::build_schema_pyramid_with(
184        dict,
185        triples,
186        &dend,
187        round,
188        type_override,
189    );
190    lap("build_schema_pyramid");
191    let predicate_stats = compute_predicate_stats(triples);
192    lap("compute_predicate_stats");
193    let char_sets = compute_char_sets(triples);
194    lap("compute_char_sets");
195    let label_index = compute_label_index(dict, triples);
196    lap("compute_label_index");
197    let meta = PyramidMeta::new(round as u32, summary, &[])
198        .with_schema(
199            sp.class_hierarchy,
200            sp.level_rollups,
201            sp.level_links,
202            sp.descriptors,
203            sp.subclass_cycles,
204            sp.disjoint_pairs,
205            sp.equivalent_pairs,
206        )
207        .with_predicate_stats(predicate_stats)
208        .with_char_sets(char_sets)
209        .with_label_index(label_index);
210    let out = (meta.encode(), dend.rounds() as u16);
211    lap("encode");
212    out
213}
214
215/// The label predicates a [`compute_label_index`] entry can come from — the
216/// common "human-readable name of this subject" terms, angle-bracketed as the
217/// dictionary stores them. Order is irrelevant (we union their ids). The same
218/// list the LABEL_INDEX section's rule L1 uses.
219const LABEL_PREDICATES: &[&str] = crate::label_index::DIRECT_LABEL_PREDICATES;
220
221/// Build the bounded, **legacy** label block of a [`PyramidLayout::Combined`]
222/// pyramid-meta (the LABEL_INDEX section, kind 8, replaces it): the display labels of
223/// the most-connected labeled subjects, sorted by the label's lowercased form.
224/// Ranking keeps autocomplete useful on a huge graph (the prominent entities
225/// survive the bound); a graph with fewer than `MAX_LABELS` labels keeps them
226/// all. Deterministic (degree, then subject id, then label) so builds are
227/// reproducible. O(triples) transient memory, freed before the file is written.
228fn compute_label_index(
229    dict: &Dictionary,
230    triples: &[(u32, u32, u32)],
231) -> Vec<crate::meta::LabelEntry> {
232    use crate::terms::{is_literal, literal_lexical};
233    use std::collections::{HashMap, HashSet};
234    const MAX_LABELS: usize = 8192;
235
236    // Resolve the label predicates that actually occur in this graph.
237    let label_pids: HashSet<u32> = LABEL_PREDICATES
238        .iter()
239        .filter_map(|p| dict.predicate_id(p))
240        .collect();
241    if label_pids.is_empty() {
242        return Vec::new();
243    }
244    // Subject degree (triple count) — the ranking used to bound the index.
245    let mut degree: HashMap<u32, u32> = HashMap::new();
246    for &(s, _p, _o) in triples {
247        *degree.entry(s).or_insert(0) += 1;
248    }
249    // Candidate (subject, label) pairs, deduped on (subject, lowercased label).
250    let mut seen: HashSet<(u32, String)> = HashSet::new();
251    let mut candidates: Vec<(u32, String, u32)> = Vec::new(); // (degree, label, subject)
252    for &(s, p, o) in triples {
253        if !label_pids.contains(&p) {
254            continue;
255        }
256        let Some(term) = dict.object_term(o) else {
257            continue;
258        };
259        if !is_literal(&term) {
260            continue;
261        }
262        let Some(label) = literal_lexical(&term) else {
263            continue;
264        };
265        if label.is_empty() {
266            continue;
267        }
268        if seen.insert((s, label.to_lowercase())) {
269            candidates.push((*degree.get(&s).unwrap_or(&0), label, s));
270        }
271    }
272    // Keep the most-connected entities when over budget: rank by degree desc,
273    // then subject asc, then label asc (deterministic).
274    if candidates.len() > MAX_LABELS {
275        candidates.sort_by(|a, b| {
276            b.0.cmp(&a.0)
277                .then_with(|| a.2.cmp(&b.2))
278                .then_with(|| a.1.cmp(&b.1))
279        });
280        candidates.truncate(MAX_LABELS);
281    }
282    // Final order: by lowercased label (search key), then label, then subject.
283    candidates.sort_by(|a, b| {
284        a.1.to_lowercase()
285            .cmp(&b.1.to_lowercase())
286            .then_with(|| a.1.cmp(&b.1))
287            .then_with(|| a.2.cmp(&b.2))
288    });
289    candidates
290        .into_iter()
291        .map(|(_deg, label, subject)| crate::meta::LabelEntry { label, subject })
292        .collect()
293}
294
295/// Build the default-scope full-text index section (`token → subjects`) over
296/// every literal object of `triples`: tokenize each literal into words and
297/// record the subject that carries it. Empty when the graph has no literals.
298/// This is the pre-0.4.0 index, byte for byte; the builds call
299/// `text_index::build_section` with their scope (`--text-index-graphs`,
300/// `--text-index-include`/`-exclude`). O(literal bytes) transient. The token
301/// table is compressed with `writer_codec` (the reader decompresses with
302/// `block_codec`).
303pub fn compute_text_index(dict: &Dictionary, triples: &[(u32, u32, u32)]) -> Vec<u8> {
304    crate::text_index::build_section(
305        dict,
306        &crate::text_index::TextIndexScope::default(),
307        triples,
308        std::iter::empty(),
309        writer_codec(),
310    )
311}
312
313/// The top entity **shapes** (characteristic sets): group subjects by the exact
314/// set of predicates they carry, keep the most common. Bounded to `MAX_CHAR_SETS`
315/// and sorted deterministically (by subject count, then predicate list) so the
316/// encoding is reproducible. O(triples) transient memory.
317fn compute_char_sets(triples: &[(u32, u32, u32)]) -> Vec<crate::meta::CharSet> {
318    use std::collections::{BTreeSet, HashMap};
319    const MAX_CHAR_SETS: usize = 128;
320    let mut by_subject: HashMap<u32, BTreeSet<u32>> = HashMap::new();
321    for &(s, p, _o) in triples {
322        by_subject.entry(s).or_default().insert(p);
323    }
324    let mut shapes: HashMap<Vec<u32>, u64> = HashMap::new();
325    for set in by_subject.into_values() {
326        *shapes.entry(set.into_iter().collect()).or_insert(0) += 1;
327    }
328    let mut v: Vec<crate::meta::CharSet> = shapes
329        .into_iter()
330        .map(|(predicates, subjects)| crate::meta::CharSet {
331            predicates,
332            subjects,
333        })
334        .collect();
335    v.sort_by(|a, b| {
336        b.subjects
337            .cmp(&a.subjects)
338            .then_with(|| a.predicates.cmp(&b.predicates))
339    });
340    v.truncate(MAX_CHAR_SETS);
341    v
342}
343
344/// Per-predicate cardinality for the cost-based planner, in one pass over the
345/// triples (deduped, so a per-(subject,predicate) count is its distinct-object
346/// count). Returned sorted by predicate id for a reproducible encoding. Holds a
347/// transient `(subject -> count, object -> count)` map per predicate — O(triples)
348/// memory, freed before the file is written.
349fn compute_predicate_stats(triples: &[(u32, u32, u32)]) -> Vec<crate::meta::PredStat> {
350    use std::collections::HashMap;
351    #[allow(clippy::type_complexity)]
352    let mut acc: HashMap<u32, (HashMap<u32, u32>, HashMap<u32, u32>, u64)> = HashMap::new();
353    for &(s, p, o) in triples {
354        let e = acc.entry(p).or_default();
355        *e.0.entry(s).or_insert(0) += 1;
356        *e.1.entry(o).or_insert(0) += 1;
357        e.2 += 1;
358    }
359    let mut stats: Vec<crate::meta::PredStat> = acc
360        .into_iter()
361        .map(|(predicate, (subj, obj, count))| crate::meta::PredStat {
362            predicate,
363            count,
364            distinct_subjects: subj.len() as u64,
365            distinct_objects: obj.len() as u64,
366            max_objects_per_subject: subj.values().copied().max().unwrap_or(0),
367            max_subjects_per_object: obj.values().copied().max().unwrap_or(0),
368        })
369        .collect();
370    stats.sort_by_key(|p| p.predicate);
371    stats
372}
373
374/// No compression.
375pub const CODEC_NONE: u8 = 0;
376/// zstd compression (per section).
377pub const CODEC_ZSTD: u8 = 1;
378
379#[derive(Debug, thiserror::Error)]
380#[non_exhaustive]
381pub enum FileError {
382    #[error("header: {0}")]
383    Header(#[from] crate::header::HeaderError),
384    #[error("malformed container: {0}")]
385    Container(&'static str),
386    #[error("unknown codec: {0}")]
387    UnknownCodec(u8),
388    #[error("decompression failed: {0}")]
389    Decompress(std::io::Error),
390    #[error("io: {0}")]
391    Io(#[from] std::io::Error),
392}
393
394/// The codec the writer uses: zstd when the `compression` feature is on, else
395/// none. Reading honors whatever codec the header records (when supported).
396pub(crate) fn writer_codec() -> u8 {
397    if cfg!(feature = "compression") {
398        CODEC_ZSTD
399    } else {
400        CODEC_NONE
401    }
402}
403
404/// Intersection of two ascending-sorted, deduped id lists — the AND of two
405/// posting lists in a multi-word text search. Linear merge, output sorted.
406fn intersect_sorted(a: &[u32], b: &[u32]) -> Vec<u32> {
407    let mut out = Vec::with_capacity(a.len().min(b.len()));
408    let (mut i, mut j) = (0, 0);
409    while i < a.len() && j < b.len() {
410        match a[i].cmp(&b[j]) {
411            std::cmp::Ordering::Less => i += 1,
412            std::cmp::Ordering::Greater => j += 1,
413            std::cmp::Ordering::Equal => {
414                out.push(a[i]);
415                i += 1;
416                j += 1;
417            }
418        }
419    }
420    out
421}
422
423pub(crate) fn compress(codec: u8, bytes: &[u8]) -> Vec<u8> {
424    match codec {
425        // Level and frame shape are writer knobs (`crate::write_opts`); any
426        // combination decodes with every conformant zstd decoder.
427        #[cfg(feature = "compression")]
428        CODEC_ZSTD => {
429            let level = crate::write_opts::zstd_level();
430            match crate::write_opts::frame_shape() {
431                crate::write_opts::FrameShape::Stream => zstd::encode_all(bytes, level),
432                crate::write_opts::FrameShape::Sized => zstd::bulk::compress(bytes, level),
433            }
434            .expect("zstd encode is infallible in-memory")
435        }
436        _ => bytes.to_vec(),
437    }
438}
439
440/// The streaming twin of [`compress`]: the same codec, level and frame shape,
441/// read from `src` (exactly `src_len` bytes) and written to `dst`, for a
442/// payload the memory-bounded build keeps on disk (the TEXT_INDEX token
443/// table). A streaming frame is what `zstd::encode_all` writes, through the
444/// same `Encoder`. A sized frame is told the source size up front
445/// (`set_pledged_src_size`), so it picks the same small-input parameters and
446/// records the same content size as `zstd::bulk::compress`, without holding
447/// the payload. The encoder cuts blocks from its own input buffer, not from
448/// how the input was handed to it, so how `src` hands out its bytes never
449/// changes the output.
450///
451/// It equals [`compress`]'s one-shot output only while the payload fits the
452/// level's window (4 MiB at level 9): past it the streaming encoder's ring
453/// buffer wraps and it finds slightly different matches. A payload that one
454/// writer streams and another holds whole must therefore go through
455/// [`compress_streamed`] on the in-memory side — the token table does.
456/// `text_index_spill` pins both facts.
457#[cfg(not(target_arch = "wasm32"))]
458pub(crate) fn compress_to(
459    codec: u8,
460    src_len: u64,
461    src: &mut dyn std::io::Read,
462    dst: &mut dyn std::io::Write,
463) -> std::io::Result<()> {
464    match codec {
465        #[cfg(feature = "compression")]
466        CODEC_ZSTD => zstd_stream(src_len, src, dst),
467        _ => {
468            let _ = src_len;
469            std::io::copy(src, dst).map(|_| ())
470        }
471    }
472}
473
474/// [`compress`] through the streaming encoder: byte for byte what
475/// [`compress_to`] writes for the same payload, whatever its size. For the
476/// in-memory writer of a payload the memory-bounded build compresses from
477/// disk (the TEXT_INDEX token table), so the two builds write the same bytes
478/// past the zstd window too. Below it this is [`compress`]'s output.
479pub(crate) fn compress_streamed(codec: u8, bytes: &[u8]) -> Vec<u8> {
480    match codec {
481        #[cfg(feature = "compression")]
482        CODEC_ZSTD => {
483            let mut out = Vec::with_capacity(bytes.len() / 4 + 64);
484            zstd_stream(bytes.len() as u64, &mut &bytes[..], &mut out)
485                .expect("zstd encode is infallible in-memory");
486            out
487        }
488        _ => bytes.to_vec(),
489    }
490}
491
492/// The streaming zstd encoder at the writer's level and frame shape.
493#[cfg(feature = "compression")]
494fn zstd_stream(
495    src_len: u64,
496    src: &mut dyn std::io::Read,
497    dst: &mut dyn std::io::Write,
498) -> std::io::Result<()> {
499    let mut enc = zstd::stream::Encoder::new(dst, crate::write_opts::zstd_level())?;
500    if crate::write_opts::frame_shape() == crate::write_opts::FrameShape::Sized {
501        enc.set_pledged_src_size(Some(src_len))?;
502        enc.include_contentsize(true)?;
503    }
504    let copied = std::io::copy(src, &mut enc)?;
505    if copied != src_len {
506        return Err(std::io::Error::other(format!(
507            "zstd stream: {copied} bytes read, {src_len} announced"
508        )));
509    }
510    enc.finish()?;
511    Ok(())
512}
513
514pub(crate) fn decompress(codec: u8, bytes: &[u8]) -> Result<Vec<u8>, FileError> {
515    match codec {
516        CODEC_NONE => Ok(bytes.to_vec()),
517        // Native (the `compression` feature — always on for the CLI, off for
518        // wasm) decodes through the C `zstd` library, the same one
519        // [`compress`] encodes with. It is roughly 2× the pure-Rust decoder
520        // and speeds every read path, not just export; the multi-pass tight
521        // budget of a bounded export (`--memory-budget-mb`) re-decodes the
522        // dictionary, so a faster decoder is the single largest lever there.
523        // A valid zstd frame decodes to the same bytes under any conformant
524        // decoder, so the output is byte-for-byte what `ruzstd` produced.
525        #[cfg(feature = "compression")]
526        CODEC_ZSTD => zstd::decode_all(bytes).map_err(FileError::Decompress),
527        // Pure-Rust decode so a target WITHOUT a C toolchain (wasm, or a
528        // `--no-default-features` build) can still read compressed files.
529        // This is the ONLY decode path wasm ever compiles — the C `zstd`
530        // crate is gated behind `compression`, which wasm never enables.
531        #[cfg(not(feature = "compression"))]
532        CODEC_ZSTD => {
533            use std::io::Read;
534            let mut dec = ruzstd::decoding::StreamingDecoder::new(bytes)
535                .map_err(|e| FileError::Decompress(std::io::Error::other(e.to_string())))?;
536            let mut out = Vec::new();
537            dec.read_to_end(&mut out).map_err(FileError::Decompress)?;
538            Ok(out)
539        }
540        other => Err(FileError::UnknownCodec(other)),
541    }
542}
543
544/// Bytes this close are cheaper fetched as one read than as two round trips:
545/// tiles are laid back-to-back so this only ever bridges a tile already made
546/// resident by an earlier window — keep it tight to avoid re-fetching it.
547const TILE_COALESCE_GAP: u64 = 4096;
548
549/// The dictionary chunks a query's output terms touch are scattered across the
550/// section (terms are sorted, output ids are not), so byte-adjacency is rare.
551/// A wider gap trades a little over-fetch for far fewer round trips — the right
552/// call on a latency-bound remote read, where one skipped 64 KiB chunk is much
553/// cheaper than another request's RTT.
554const DICT_COALESCE_GAP: u64 = 64 * 1024;
555
556/// The default `rete export --memory-budget-mb` when the flag is omitted — the
557/// same number `rete build`/`merge` default to, so one figure governs both the
558/// build and the export of a file. Comfortably above a sub-4-GB-dictionary
559/// file's decompressed dictionary (those export in a single pass at today's
560/// speed), and far under a large VM's RAM so a 50-GB-class file is bounded
561/// rather than OOM. See [`split_memory_budget`].
562pub const DEFAULT_EXPORT_BUDGET_MB: u64 = 4096;
563
564/// The largest default memory budget, in MiB, of a lazily-opened file — the
565/// bound on its dictionary chunk cache, index tile cache and raw block cache,
566/// split as [`split_memory_budget`] describes. The default actually applied is
567/// sized from the memory the process can still use, half of it, between
568/// [`crate::sysmem::MIN_DEFAULT_QUERY_BUDGET_MB`] and this figure (see
569/// [`crate::sysmem::default_query_budget_mb`]; this figure whenever the memory
570/// cannot be read). `rete sparql --memory-budget-mb` (and the other query
571/// commands) default to it too, and so does every client that opens through
572/// [`Rete::open_ranged_lazy`] (Python, R, Java, the relay), so a long-lived
573/// session no longer grows with every tile a query ever touched. The same
574/// figure as [`DEFAULT_EXPORT_BUDGET_MB`].
575pub const DEFAULT_QUERY_BUDGET_MB: u64 = DEFAULT_EXPORT_BUDGET_MB;
576
577/// The [`Rete::spill_threshold`] a memory budget implies: an eighth of it. A
578/// spilling `COUNT(DISTINCT …)` buffer can briefly occupy about twice its
579/// threshold while it sorts, so this keeps it within a quarter of the budget.
580pub fn spill_threshold_for(budget: u64) -> u64 {
581    budget / 8
582}
583
584/// The lowest byte cap the dictionary chunk cache is ever given, whatever the
585/// budget. It only has to guarantee forward progress and hold a handful of
586/// chunks (a single front-coded run can dwarf the 64 KiB chunk budget — a
587/// base64-embedded image, say). Correctness never depends on it: the cache's
588/// `Arc` handout keeps a chunk alive across its one decode even if it is
589/// evicted the instant it is inserted, so a full dump completes at *any* cap.
590/// This floor only keeps a pathologically small `--memory-budget-mb` from
591/// thrashing the whole dictionary chunk-by-chunk.
592const MIN_DICT_CACHE_CAP: u64 = 64 << 20; // 64 MiB
593
594/// Upper bound of the budget share [`split_memory_budget`] holds back for
595/// transient, uncached allocations (see the `reserve` there).
596const TRANSIENT_RESERVE_MAX: u64 = 128 << 20; // 128 MiB
597
598/// How a total `--memory-budget-mb` splits across the export's three bounded
599/// consumers. The three caps sum to the total less a small reserve (≤ 128 MiB)
600/// for the window's buffers and one window's in-flight reads, which no cache
601/// owns. [`Rete::set_memory_budget`] further subtracts the open's fixed
602/// directories ([`Rete::fixed_overhead`]) from the dictionary share, so the
603/// live heap of a dump stays within the total.
604#[derive(Debug, Clone, Copy)]
605pub struct MemoryBudget {
606    /// Raw file-block LRU (the `BlockCacheReader` that backs the ranged reader).
607    pub block_cache: u64,
608    /// Decompressed dictionary chunk bodies (the old ~4.3 GB export floor).
609    pub dict_cache: u64,
610    /// Decoded index tiles of the scanned permutation.
611    pub tile_cache: u64,
612}
613
614/// Split a total byte budget across the block cache, the dictionary chunk cache
615/// and the index tile cache (bounded-export phase 2, plan §5.2). `u64::MAX`
616/// (unlimited, e.g. `--in-memory` or `--memory-budget-mb 0`) passes straight
617/// through to every consumer.
618///
619/// The dictionary is the dominant consumer on scholar files (literal-heavy
620/// object section), and its chunks recur across windows, so it gets the bulk of
621/// the budget; the tile cache is small (a scanned permutation is far smaller
622/// than the dictionary) and the block cache stays modest, capped like every
623/// other ranged reader at [`crate::block_cache::DEFAULT_CACHE_CAP`].
624pub fn split_memory_budget(total: u64) -> MemoryBudget {
625    if total == u64::MAX {
626        return MemoryBudget {
627            block_cache: u64::MAX,
628            dict_cache: u64::MAX,
629            tile_cache: u64::MAX,
630        };
631    }
632    // Raw file blocks: min(256 MiB, total/8). Repeated directory/header reads
633    // must stay cheap, but this cache is not where the floor lived.
634    let block_cache = (total / 8).min(crate::block_cache::DEFAULT_CACHE_CAP);
635    // Scanned-permutation tiles: modest — tens of MB on epfl-class files. Cap
636    // at total/16 (≤ 256 MiB) so the dictionary keeps the rest.
637    let tile_cache = (total / 16).min(256 << 20);
638    // Held back for what no cache owns: the window's id and string buffers,
639    // the coalesced read spans and freshly decoded bodies of one window's
640    // prefetch before they enter the cache, and the program itself. Measured at
641    // ~80-120 MB on epfl and openaire-relation (dev/export-memory), so 1/32 of
642    // the budget, capped at 128 MiB.
643    let reserve = (total / 32).min(TRANSIENT_RESERVE_MAX);
644    // The dictionary chunk cache gets everything else, floored so a tiny budget
645    // still holds a working set of chunks.
646    let dict_cache = total
647        .saturating_sub(block_cache)
648        .saturating_sub(tile_cache)
649        .saturating_sub(reserve)
650        .max(MIN_DICT_CACHE_CAP);
651    MemoryBudget {
652        block_cache,
653        dict_cache,
654        tile_cache,
655    }
656}
657
658/// Heap an open `Rete` holds outside its bounded caches, by structure — see
659/// [`Rete::fixed_overhead`].
660#[derive(Debug, Clone, Copy, Default)]
661pub struct FixedOverhead {
662    /// The four dictionary sections' chunk directories.
663    pub dict_directory: u64,
664    /// The default graph's tile directories (every carried permutation).
665    pub index_directory: u64,
666    /// The named-graph directory plus the open graphs' tile directories.
667    pub named_graphs: u64,
668}
669
670impl FixedOverhead {
671    /// The sum of every part.
672    pub fn total(&self) -> u64 {
673        self.dict_directory + self.index_directory + self.named_graphs
674    }
675}
676
677/// Format a cache byte cap for `RETE_OPEN_DEBUG` (`unlimited` for `u64::MAX`).
678fn fmt_cap(cap: u64) -> String {
679    if cap == u64::MAX {
680        "unlimited".to_string()
681    } else {
682        format!("{}MiB", cap >> 20)
683    }
684}
685
686/// Fetch a set of ascending, disjoint byte ranges, coalescing ranges whose gap
687/// is at most `gap` into one span, then fetching the spans through
688/// [`RangeReader::read_many`] (which a parallelizable reader issues
689/// concurrently). Returns each requested range's bytes in order; `None` if any
690/// read fails.
691fn read_coalesced<R: RangeReader + ?Sized>(
692    reader: &R,
693    ranges: &[ByteRange],
694    gap: u64,
695) -> Option<Vec<Vec<u8>>> {
696    // Build the coalesced spans and remember which span each input range maps
697    // into, so the fetched span blobs can be sliced back apart in order.
698    let mut spans: Vec<(u64, u64)> = Vec::new();
699    let mut span_of: Vec<usize> = Vec::with_capacity(ranges.len());
700    let mut i = 0;
701    while i < ranges.len() {
702        let start = ranges[i].offset;
703        let mut end = ranges[i].offset.checked_add(ranges[i].len)?;
704        let mut j = i + 1;
705        while j < ranges.len() {
706            let r = &ranges[j];
707            if r.offset < end || r.offset - end > gap {
708                break;
709            }
710            end = r.offset.checked_add(r.len)?;
711            j += 1;
712        }
713        let si = spans.len();
714        spans.push((start, end - start));
715        for _ in i..j {
716            span_of.push(si);
717        }
718        i = j;
719    }
720    let blobs = reader.read_many(&spans).ok()?;
721    if blobs.len() != spans.len() {
722        return None;
723    }
724    let mut out = Vec::with_capacity(ranges.len());
725    for (k, r) in ranges.iter().enumerate() {
726        let (span_start, _) = spans[span_of[k]];
727        let blob = &blobs[span_of[k]];
728        let lo = (r.offset - span_start) as usize;
729        let hi = lo.checked_add(r.len as usize)?;
730        out.push(blob.get(lo..hi)?.to_vec());
731    }
732    Some(out)
733}
734
735/// Content hash (first 16 bytes of blake3) over the file payload sections.
736/// Identifies the immutable content independent of the header.
737fn content_hash(parts: &[&[u8]]) -> [u8; 16] {
738    let mut h = blake3::Hasher::new();
739    for p in parts {
740        h.update(p);
741    }
742    let mut out = [0u8; 16];
743    out.copy_from_slice(&h.finalize().as_bytes()[..16]);
744    out
745}
746
747/// A resolved triple as terms.
748pub type TermTriple = (String, String, String);
749
750/// What a filtered dump will cost, before it runs — see
751/// [`Rete::dump_plan`](crate::Rete::dump_plan).
752#[derive(Debug, Clone, Copy, PartialEq, Eq)]
753pub struct DumpPlan {
754    /// The routed scan this dump will run. `None` when it provably matches
755    /// nothing without touching the index: an absent graph IRI, or a bound term
756    /// the dictionary does not contain.
757    pub scan: Option<crate::index::ScanPlan>,
758    /// The file's whole dictionary section — the **ceiling** on what resolving
759    /// the matched rows' terms can fault, and on a literal-heavy file the
760    /// dominant term in a dump's real cost.
761    ///
762    /// It is deliberately not an estimate. A dump that matches one row faults
763    /// only the chunks that row's three terms live in, but nothing in the tile
764    /// directories says which chunks those are or how big they are, and a
765    /// preview that guessed would be worse than one that states the bound.
766    pub dictionary_bytes: u64,
767}
768
769/// One labelled byte region of a `.rete` file image (see
770/// [`Rete::file_layout`]). `kind` is a stable machine tag: `header`,
771/// `metadata`, `dictionary`, `directory`, `tile`, `pyramid`, `named-graphs`.
772#[derive(Debug, Clone)]
773pub struct LayoutSegment {
774    pub kind: &'static str,
775    pub label: String,
776    pub offset: u64,
777    pub len: u64,
778}
779
780/// A byte range in the `.rete` file image.
781#[derive(Debug, Clone, Copy, PartialEq, Eq)]
782pub struct ByteRange {
783    pub offset: u64,
784    pub len: u64,
785}
786
787impl ByteRange {
788    pub fn end(self) -> u64 {
789        self.offset + self.len
790    }
791}
792
793/// Why a triple-pattern result is present in the file.
794#[derive(Debug, Clone, PartialEq, Eq)]
795pub struct TripleProvenance {
796    /// Matched triple resolved to canonical N-Triples tokens.
797    pub terms: TermTriple,
798    /// Matched triple in dictionary ID space.
799    pub ids: Triple,
800    /// Named graph IRI, or `None` for the default graph.
801    pub graph: Option<String>,
802    /// The resolved ID-space pattern that was matched.
803    pub matched_pattern: Pattern,
804    /// Permutation selected to answer the pattern.
805    pub index_permutation: IndexPermutation,
806    /// File byte range containing the dictionary container.
807    pub dictionary_range: ByteRange,
808    /// File byte range containing the permutation index container.
809    pub index_range: ByteRange,
810    /// File byte range containing the selected permutation payload inside the
811    /// index container.
812    pub index_section_range: ByteRange,
813    /// File byte range containing the pyramid metadata, when present.
814    pub pyramid_range: Option<ByteRange>,
815    /// Physical tile identifier, once tile directories are materialized.
816    /// Physical tile identifier (`PERM/index`, e.g. `POS/3`) for tiled (v0.2)
817    /// files; `None` for pre-tiling files.
818    pub tile: Option<String>,
819    /// File byte range of that (compressed) tile — the exact bytes a ranged
820    /// client would fetch to re-derive this match.
821    pub tile_range: Option<ByteRange>,
822}
823
824/// Encode a length-prefixed container of byte sections, each compressed with
825/// `codec` independently (so a range-reading client decompresses only the
826/// sections it fetches). Stored length is the *compressed* length.
827fn encode_container(sections: &[&[u8]], codec: u8) -> Vec<u8> {
828    let mut out = Vec::new();
829    write_uvarint(&mut out, sections.len() as u64);
830    for s in sections {
831        let payload = compress(codec, s);
832        write_uvarint(&mut out, payload.len() as u64);
833        out.extend_from_slice(&payload);
834    }
835    out
836}
837
838/// Decode a container into owned, decompressed sections.
839fn decode_container(bytes: &[u8], codec: u8) -> Result<Vec<Vec<u8>>, FileError> {
840    let (n, mut pos) = read_uvarint(bytes).ok_or(FileError::Container("truncated count"))?;
841    // `n` is untrusted; each section needs ≥1 byte, so cap the pre-allocation at
842    // the buffer length rather than trusting the count (avoids an OOM on a bogus
843    // header pointing at a small region).
844    let mut out = Vec::with_capacity((n as usize).min(bytes.len()));
845    for _ in 0..n {
846        let (len, used) =
847            read_uvarint(&bytes[pos..]).ok_or(FileError::Container("truncated length"))?;
848        pos += used;
849        let end = pos + len as usize;
850        if end > bytes.len() {
851            return Err(FileError::Container("section overruns buffer"));
852        }
853        out.push(decompress(codec, &bytes[pos..end])?);
854        pos = end;
855    }
856    Ok(out)
857}
858
859fn checked_end(off: u64, len: u64) -> Result<u64, FileError> {
860    off.checked_add(len)
861        .ok_or(FileError::Container("section range overflows"))
862}
863
864/// Per-chunk budget for dictionary section bodies — same reasoning as
865/// [`crate::index::INDEX_TILE_BUDGET`]: one fetch, one decompress per touch.
866const DICT_CHUNK_BUDGET: usize = crate::write_opts::DEFAULT_DICT_CHUNK_BYTES;
867
868/// Encode one dictionary section as a chunked payload (format v0.2):
869/// `[header_len, raw header (term_count/interval/restart table)]
870///  [num_chunks; per chunk: Δfirst_run, key_len, key, comp_len]
871///  [individually compressed run-aligned body slices]`.
872/// The header keeps its original encoding, so restart offsets stay valid in
873/// the section's coordinate space.
874///
875/// `key` is the chunk's **routing separator**, not its first term: the
876/// shortest string strictly above the previous chunk's last term and at most
877/// the chunk's own first term (empty for chunk 0). See
878/// [`crate::dict::SectionChunk::key`] for the invariant and
879/// [`crate::dict::shortest_separator`] for the choice. Storing the first term
880/// verbatim — what this wrote before — is the degenerate case of the same
881/// invariant, so readers of either vintage route both correctly; the
882/// separator is simply orders of magnitude smaller when the boundary term is a
883/// long literal.
884fn encode_chunked_dict_section(raw: &[u8], codec: u8) -> Vec<u8> {
885    let meta = crate::dict::parse_meta(raw).unwrap_or(crate::dict::SectionMeta {
886        term_count: 0,
887        restart_interval: 1,
888        restart_offsets: Vec::new(),
889    });
890    let body_start = meta
891        .restart_offsets
892        .first()
893        .copied()
894        .unwrap_or(raw.len() as u64);
895    let header = &raw[..(body_start.min(raw.len() as u64)) as usize];
896
897    // Split runs into chunks by body-byte budget (whole runs only).
898    let chunk_budget = crate::write_opts::dict_chunk_budget() as u64;
899    let n_runs = meta.restart_offsets.len();
900    let mut bounds: Vec<(usize, u64, u64)> = Vec::new(); // (first_run, start, end)
901    let mut r = 0;
902    while r < n_runs {
903        let start = meta.restart_offsets[r];
904        let mut r2 = r + 1;
905        while r2 < n_runs && meta.restart_offsets[r2] - start < chunk_budget {
906            r2 += 1;
907        }
908        let end = if r2 < n_runs {
909            meta.restart_offsets[r2]
910        } else {
911            raw.len() as u64
912        };
913        bounds.push((r, start, end));
914        r = r2;
915    }
916
917    // Chunks are independent: compress them across all cores, as the index
918    // tiles are (`par_iter` keeps the order, so the bytes are unchanged). At a
919    // high `--zstd-level` this serial loop was most of a build's wall time.
920    #[cfg(feature = "parallel")]
921    let compressed: Vec<Vec<u8>> = {
922        use rayon::prelude::*;
923        bounds
924            .par_iter()
925            .map(|&(_, s, e)| compress(codec, &raw[s as usize..e as usize]))
926            .collect()
927    };
928    #[cfg(not(feature = "parallel"))]
929    let compressed: Vec<Vec<u8>> = bounds
930        .iter()
931        .map(|&(_, s, e)| compress(codec, &raw[s as usize..e as usize]))
932        .collect();
933    let mut out = Vec::new();
934    write_uvarint(&mut out, header.len() as u64);
935    out.extend_from_slice(header);
936    write_uvarint(&mut out, bounds.len() as u64);
937    let mut prev_run = 0usize;
938    // The previous chunk's last term — the separator's lower bound. Decoding it
939    // walks at most `restart_interval` front-coded entries of one chunk, which
940    // is nothing beside the zstd pass over the same 64 KiB.
941    let mut prev_last: Option<Vec<u8>> = None;
942    for (i, (&(first_run, start, end), comp)) in bounds.iter().zip(&compressed).enumerate() {
943        let key = if i == 0 {
944            // Everything routes to chunk 0; it needs no separator at all.
945            Vec::new()
946        } else {
947            let first = crate::dict::run_first_term(raw, start as usize).unwrap_or_default();
948            match &prev_last {
949                // Undecodable predecessor: the verbatim first term is always a
950                // valid separator, just not a short one.
951                None => first,
952                Some(pl) => crate::dict::shortest_separator(pl, &first),
953            }
954        };
955        if i + 1 < bounds.len() {
956            // The last run of THIS chunk starts one run before the next chunk's.
957            let last_run_off = meta.restart_offsets[bounds[i + 1].0 - 1] as usize;
958            prev_last = crate::dict::run_last_term(raw, last_run_off, end as usize);
959        }
960        write_uvarint(&mut out, (first_run - prev_run) as u64);
961        write_uvarint(&mut out, key.len() as u64);
962        out.extend_from_slice(&key);
963        write_uvarint(&mut out, comp.len() as u64);
964        prev_run = first_run;
965    }
966    for comp in &compressed {
967        out.extend_from_slice(comp);
968    }
969    out
970}
971
972/// A parsed chunked-dict-section directory entry: the chunk's run/key/body
973/// coordinates plus its compressed byte range *within the payload*. `key` is a
974/// routing separator, not a term — see [`crate::dict::SectionChunk::key`].
975struct DictChunkEntry {
976    first_run: usize,
977    key: Vec<u8>,
978    body_start: u64,
979    start: u64,
980    end: u64,
981}
982
983/// Parse a chunked dictionary section's header + directory (not the chunks).
984/// `bytes` may be a prefix of the payload; compressed ranges validate against
985/// `total_len`.
986fn parse_chunked_dict_dir(
987    bytes: &[u8],
988    total_len: u64,
989) -> Result<(crate::dict::SectionMeta, Vec<DictChunkEntry>), FileError> {
990    let mut pos = 0usize;
991    let take = |pos: &mut usize| -> Result<u64, FileError> {
992        let (v, n) = read_uvarint(bytes.get(*pos..).unwrap_or(&[]))
993            .ok_or(FileError::Container("truncated dict chunk directory"))?;
994        *pos += n;
995        Ok(v)
996    };
997    let header_len = take(&mut pos)? as usize;
998    let header = bytes
999        .get(pos..pos.saturating_add(header_len))
1000        .ok_or(FileError::Container("truncated dict header"))?;
1001    let meta = crate::dict::parse_meta(header)
1002        .map_err(|_| FileError::Container("malformed dict header"))?;
1003    pos += header_len;
1004
1005    let num_chunks = take(&mut pos)? as usize;
1006    let mut entries = Vec::with_capacity(num_chunks.min(bytes.len()));
1007    let mut lens = Vec::with_capacity(num_chunks.min(bytes.len()));
1008    let mut prev_run = 0usize;
1009    for _ in 0..num_chunks {
1010        let drun = take(&mut pos)? as usize;
1011        let klen = take(&mut pos)? as usize;
1012        let key = bytes
1013            .get(pos..pos.saturating_add(klen))
1014            .ok_or(FileError::Container("truncated dict chunk key"))?
1015            .to_vec();
1016        pos += klen;
1017        let clen = take(&mut pos)?;
1018        let first_run = prev_run + drun;
1019        let body_start = meta
1020            .restart_offsets
1021            .get(first_run)
1022            .copied()
1023            .ok_or(FileError::Container("dict chunk run out of range"))?;
1024        entries.push(DictChunkEntry {
1025            first_run,
1026            key,
1027            body_start,
1028            start: 0,
1029            end: 0,
1030        });
1031        lens.push(clen);
1032        prev_run = first_run;
1033    }
1034    let mut start = pos as u64;
1035    for (e, len) in entries.iter_mut().zip(lens) {
1036        let end = start
1037            .checked_add(len)
1038            .filter(|&e| e <= total_len)
1039            .ok_or(FileError::Container("dict chunk overruns section"))?;
1040        e.start = start;
1041        e.end = end;
1042        start = end;
1043    }
1044    Ok((meta, entries))
1045}
1046
1047/// Fetch and parse a remote chunked dict section's header + directory: read a
1048/// small prefix and grow it geometrically until it parses, never fetching past
1049/// the section.
1050/// Read a chunked dictionary section's directory over a range reader WITHOUT
1051/// materializing the section-wide restart table. That table is one offset per
1052/// restart run — a 50 M-term section has ~3 M of them (~24 MiB resident), and
1053/// holding it is an iOS-Safari OOM on a big remote file. We read only the tiny
1054/// header prefix (term_count / interval) and the chunk directory, skipping the
1055/// restart-table bytes entirely; per-run offsets are derived per chunk on fault
1056/// (`SectionChunk::run_offsets`). The returned meta has an empty
1057/// `restart_offsets`, which the chunked lookups read as "derive per chunk".
1058fn read_dict_dir_ranged<R: RangeReader>(
1059    reader: &R,
1060    section: ByteRange,
1061) -> Result<(crate::dict::SectionMeta, Vec<DictChunkEntry>), FileError> {
1062    let total = section.len;
1063    // Initial prefix: the header prefix ([header_len][term_count][interval]) and,
1064    // for a *small* section, the whole chunk directory too — so those still cost
1065    // a single read. A big section has a huge restart table between the header
1066    // and the directory; we detect that (dir_start past the prefix) and range-
1067    // read only the directory below, never fetching the table.
1068    let init = 8192.min(total); // never over-read past the section (a tiny/empty
1069                                // section holds only its header + a stub directory)
1070    let head = reader.read_at(section.offset, init)?;
1071    let (header_len, n0) =
1072        read_uvarint(&head).ok_or(FileError::Container("truncated dict header len"))?;
1073    let hbase = n0; // first byte of the header body
1074    let (term_count, n1) = read_uvarint(head.get(hbase..).unwrap_or(&[]))
1075        .ok_or(FileError::Container("truncated dict term_count"))?;
1076    let (restart_interval, _n2) = read_uvarint(head.get(hbase + n1..).unwrap_or(&[]))
1077        .ok_or(FileError::Container("truncated dict interval"))?;
1078    if restart_interval == 0 {
1079        return Err(FileError::Container("zero restart interval"));
1080    }
1081    // The chunk directory begins right after the header body — i.e. past the
1082    // `header_len` bytes, which include the restart table we never materialize.
1083    let dir_start = (hbase as u64)
1084        .checked_add(header_len)
1085        .filter(|&d| d <= total)
1086        .ok_or(FileError::Container("dict header overruns section"))?;
1087    let dir_total = total - dir_start;
1088    let meta = crate::dict::SectionMeta {
1089        term_count: term_count as u32,
1090        restart_interval: restart_interval as u32,
1091        restart_offsets: Vec::new(),
1092    };
1093    let finish = |mut entries: Vec<DictChunkEntry>| {
1094        for e in &mut entries {
1095            e.start += dir_start; // dir-relative → section-relative
1096            e.end += dir_start;
1097        }
1098        (meta.clone(), entries)
1099    };
1100    // Fast path: the directory already sits in the prefix we read (small section
1101    // — its restart table is tiny, so the ~few KiB over-read is negligible).
1102    // A short prefix is not wasted: those bytes seed the probe below.
1103    let mut have: Vec<u8> = Vec::new();
1104    if dir_start < head.len() as u64 {
1105        match parse_chunk_dir_only(&head[dir_start as usize..], dir_total)? {
1106            ChunkDirParse::Done(entries) => return Ok(finish(entries)),
1107            ChunkDirParse::Truncated { .. } => have = head[dir_start as usize..].to_vec(),
1108        }
1109    }
1110    // Big section: range-read the directory on its own, skipping the table.
1111    //
1112    // Nothing records the directory's byte length, so it has to be probed. Two
1113    // things keep that probe near ONE directory's worth of bytes. Each round
1114    // **appends** to the bytes already held instead of re-reading from the
1115    // start — the previous loop re-read the whole prefix every time, so a
1116    // 234 MB directory (epfl-infoscience's object section) cost 537 MB of range
1117    // reads to fetch. And a truncated parse reports how many entries fit in how
1118    // many bytes, which extrapolates the rest instead of blindly doubling. The
1119    // extrapolation may only quadruple a round, so a wild guess on a directory
1120    // with wildly uneven entries (one stored term can be hundreds of KB) can
1121    // never fetch the section body.
1122    let mut want = 4096u64.min(dir_total).max(1);
1123    loop {
1124        let held = have.len() as u64;
1125        if want > held {
1126            let extra = reader.read_at(section.offset + dir_start + held, want - held)?;
1127            if extra.is_empty() {
1128                return Err(FileError::Container("truncated dict chunk directory"));
1129            }
1130            have.extend_from_slice(&extra);
1131        }
1132        let held = have.len() as u64;
1133        match parse_chunk_dir_only(&have, dir_total)? {
1134            ChunkDirParse::Done(entries) => return Ok(finish(entries)),
1135            ChunkDirParse::Truncated { .. } if held >= dir_total => {
1136                return Err(FileError::Container("truncated dict chunk directory"));
1137            }
1138            ChunkDirParse::Truncated {
1139                parsed,
1140                used,
1141                total,
1142            } => {
1143                // Extrapolate from a sample big enough to mean something;
1144                // otherwise just double. Undershooting only costs another
1145                // (append-only) round, so the estimate carries a small margin
1146                // rather than a generous one.
1147                let est = if parsed >= 16 && used > 0 {
1148                    let whole = ((used as u64) / (parsed as u64)).saturating_mul(total as u64);
1149                    whole.saturating_add(whole / 8).saturating_add(64)
1150                } else {
1151                    // Too small a sample to extrapolate from: double.
1152                    held.saturating_mul(2)
1153                };
1154                want = est
1155                    .max(held.saturating_add(1))
1156                    .min(held.saturating_mul(4))
1157                    .min(dir_total);
1158            }
1159        }
1160    }
1161}
1162
1163/// What [`parse_chunk_dir_only`] made of a chunk-directory prefix: either the
1164/// whole directory, or how far a truncated one got (the probe's step hint).
1165/// Truncation is not an error here — the caller is deliberately reading a
1166/// prefix and deciding how much more to fetch.
1167enum ChunkDirParse {
1168    Done(Vec<DictChunkEntry>),
1169    Truncated {
1170        /// Entries fully decoded from the prefix.
1171        parsed: usize,
1172        /// Bytes those entries occupied (directory-relative).
1173        used: usize,
1174        /// Chunk count the directory declares.
1175        total: usize,
1176    },
1177}
1178
1179/// Parse just the chunk directory (the bytes after a section header):
1180/// `[num_chunks][per chunk: Δfirst_run, key_len, key, comp_len]`.
1181/// Chunk byte ranges (`start`/`end`) come back relative to the directory's own
1182/// start; `body_start` is 0 (a lite section never uses it — lookups derive run
1183/// offsets per chunk). Bodies aren't needed here, so `dir` may end at the first
1184/// body as long as it covers the whole directory. A `dir` that stops mid-entry
1185/// is reported as [`ChunkDirParse::Truncated`], not an error: only bytes that
1186/// cannot be a directory at any length (a chunk range overrunning the section)
1187/// fail.
1188fn parse_chunk_dir_only(dir: &[u8], dir_total: u64) -> Result<ChunkDirParse, FileError> {
1189    let mut pos = 0usize;
1190    let take = |pos: &mut usize| -> Option<u64> {
1191        let (v, n) = read_uvarint(dir.get(*pos..).unwrap_or(&[]))?;
1192        *pos += n;
1193        Some(v)
1194    };
1195    let Some(num_chunks) = take(&mut pos) else {
1196        return Ok(ChunkDirParse::Truncated {
1197            parsed: 0,
1198            used: 0,
1199            total: 0,
1200        });
1201    };
1202    let num_chunks = num_chunks as usize;
1203    let mut entries = Vec::with_capacity(num_chunks.min(dir.len()));
1204    let mut lens: Vec<u64> = Vec::with_capacity(num_chunks.min(dir.len()));
1205    let mut prev_run = 0usize;
1206    for _ in 0..num_chunks {
1207        let entry_start = pos;
1208        let short = ChunkDirParse::Truncated {
1209            parsed: entries.len(),
1210            used: entry_start,
1211            total: num_chunks,
1212        };
1213        let (Some(drun), Some(klen)) = (take(&mut pos), take(&mut pos)) else {
1214            return Ok(short);
1215        };
1216        let (drun, klen) = (drun as usize, klen as usize);
1217        let Some(key) = dir.get(pos..pos.saturating_add(klen)) else {
1218            return Ok(short);
1219        };
1220        let key = key.to_vec();
1221        pos += klen;
1222        let Some(clen) = take(&mut pos) else {
1223            return Ok(short);
1224        };
1225        let first_run = prev_run + drun;
1226        entries.push(DictChunkEntry {
1227            first_run,
1228            key,
1229            body_start: 0,
1230            start: 0,
1231            end: 0,
1232        });
1233        lens.push(clen);
1234        prev_run = first_run;
1235    }
1236    let mut start = pos as u64;
1237    for (e, len) in entries.iter_mut().zip(lens) {
1238        let end = start
1239            .checked_add(len)
1240            .filter(|&e| e <= dir_total)
1241            .ok_or(FileError::Container("dict chunk overruns section"))?;
1242        e.start = start;
1243        e.end = end;
1244        start = end;
1245    }
1246    Ok(ChunkDirParse::Done(entries))
1247}
1248
1249/// Decode one chunked dictionary section payload into a resident
1250/// [`crate::dict::ChunkedSection`] (chunks decompressed up front — the local
1251/// open path).
1252fn decode_chunked_dict_section(
1253    payload: &[u8],
1254    codec: u8,
1255    cache: std::sync::Arc<crate::chunk_cache::ChunkCache>,
1256    section_index: u8,
1257) -> Result<crate::dict::ChunkedSection, FileError> {
1258    let (meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64)?;
1259    let chunks = entries
1260        .into_iter()
1261        .map(|e| {
1262            let body = decompress(codec, &payload[e.start as usize..e.end as usize])?;
1263            Ok((
1264                crate::dict::SectionChunk::new(e.first_run, e.key, e.body_start),
1265                body,
1266            ))
1267        })
1268        .collect::<Result<Vec<_>, FileError>>()?;
1269    Ok(crate::dict::ChunkedSection::resident(
1270        meta,
1271        chunks,
1272        cache,
1273        section_index,
1274    ))
1275}
1276
1277fn decode_dictionary_container(bytes: &[u8], codec: u8) -> Result<Dictionary, FileError> {
1278    let dsecs = decode_container(bytes, CODEC_NONE)?;
1279    if dsecs.len() != 4 {
1280        return Err(FileError::Container("expected 4 dictionary sections"));
1281    }
1282    // The four sections share one (unlimited) cache — residency is identical to
1283    // the previous per-chunk `OnceLock`s.
1284    let cache = crate::chunk_cache::ChunkCache::unlimited_arc();
1285    let mut sections = Vec::with_capacity(4);
1286    for (si, sec) in dsecs.iter().enumerate() {
1287        sections.push(decode_chunked_dict_section(
1288            sec,
1289            codec,
1290            cache.clone(),
1291            si as u8,
1292        )?);
1293    }
1294    let arr: [crate::dict::ChunkedSection; 4] = sections
1295        .try_into()
1296        .map_err(|_| FileError::Container("expected 4 dictionary sections"))?;
1297    Ok(Dictionary::from_chunked_sections(arr))
1298}
1299
1300/// Encode one permutation's tiled section payload (format v0.2):
1301/// `[num_tiles][per tile: delta(min_a), max_a - min_a, compressed_len][tiles…]`,
1302/// each tile compressed independently with `codec` so a ranged reader can
1303/// fetch and decompress exactly the tiles a query routes to. The directory
1304/// itself is uncompressed (it must be readable before any tile).
1305fn encode_tiled_section(index: &GraphIndex, si: usize, codec: u8) -> Vec<u8> {
1306    let tiles = index.tile_sections()[si];
1307    let n = tiles.len();
1308    // Tile bodies live in the index's chunk cache (keyed by (section, tile));
1309    // fetch each as a shared handle. For a locally-built index every tile is
1310    // resident, so this is a cheap `Arc` clone under an uncontended lock.
1311    // Per-tile compression is the bulk of serialization time on a large graph and
1312    // the tiles are independent, so compress them across all cores. `par_iter`
1313    // preserves order, so the output is byte-identical to the serial map.
1314    #[cfg(feature = "parallel")]
1315    let compressed: Vec<Vec<u8>> = {
1316        use rayon::prelude::*;
1317        (0..n)
1318            .into_par_iter()
1319            .map(|ti| compress(codec, &index.tile_body(si, ti)))
1320            .collect()
1321    };
1322    #[cfg(not(feature = "parallel"))]
1323    let compressed: Vec<Vec<u8>> = (0..n)
1324        .map(|ti| compress(codec, &index.tile_body(si, ti)))
1325        .collect();
1326    let mut out = Vec::new();
1327    write_uvarint(&mut out, tiles.len() as u64);
1328    let mut prev_min = 0u32;
1329    for (tile, comp) in tiles.iter().zip(&compressed) {
1330        let (min_a, max_a) = tile.leading_range();
1331        write_uvarint(&mut out, (min_a - prev_min) as u64);
1332        write_uvarint(&mut out, (max_a - min_a) as u64);
1333        write_uvarint(&mut out, comp.len() as u64);
1334        prev_min = min_a;
1335    }
1336    for comp in &compressed {
1337        out.extend_from_slice(comp);
1338    }
1339    // Tile-synopsis trailer (FLAG_TILE_SYNOPSIS): per tile, the inclusive min/max
1340    // of the two non-leading columns `(min_b, span_b, min_c, span_c)`, derived
1341    // from each tile's zone map. Appended **after** the tile payloads so a reader
1342    // that predates the flag — which locates tiles by length and stops — never
1343    // reads it (backward-compatible). A reader honoring the flag reads it from the
1344    // section tail. On the (impossible for a built tile) parse failure, emit a
1345    // full range so nothing is ever wrongly pruned.
1346    for ti in 0..n {
1347        let body = index.tile_body(si, ti);
1348        let (min_b, max_b, min_c, max_c) = match crate::triples::TripleBlock::parse(&body) {
1349            Ok(b) => {
1350                let z = b.zone();
1351                (z.min_b, z.max_b, z.min_c, z.max_c)
1352            }
1353            Err(_) => (0, u32::MAX, 0, u32::MAX),
1354        };
1355        write_uvarint(&mut out, min_b as u64);
1356        write_uvarint(&mut out, (max_b - min_b) as u64);
1357        write_uvarint(&mut out, min_c as u64);
1358        write_uvarint(&mut out, (max_c - min_c) as u64);
1359    }
1360    out
1361}
1362
1363/// A parsed v0.2 tile-directory entry: leading-id range plus the tile's byte
1364/// range *within the section payload*.
1365struct TileDirEntry {
1366    min_a: u32,
1367    max_a: u32,
1368    start: u64,
1369    end: u64,
1370}
1371
1372/// One tile's synopsis: inclusive min/max of the two non-leading columns.
1373type TileSynopsis = (u32, u32, u32, u32);
1374
1375/// Parse the **tile-synopsis trailer** (when [`FLAG_TILE_SYNOPSIS`] is set): the
1376/// `num_tiles × (min_b, span_b, min_c, span_c)` records that follow the last tile
1377/// payload, starting at `trailer_start` within `payload`. Returns one synopsis per
1378/// tile, in directory order. `payload` may be just the trailer slice (remote) or
1379/// the whole section (local); `trailer_start` is the offset of the trailer within
1380/// it. A short/garbled trailer yields `None` (the caller keeps `None` synopses —
1381/// pruning simply doesn't fire, never a wrong result).
1382fn parse_tile_synopsis(
1383    payload: &[u8],
1384    trailer_start: usize,
1385    num_tiles: usize,
1386) -> Option<Vec<TileSynopsis>> {
1387    let mut pos = trailer_start;
1388    let take = |pos: &mut usize| -> Option<u32> {
1389        let (v, n) = read_uvarint(payload.get(*pos..)?)?;
1390        *pos += n;
1391        u32::try_from(v).ok()
1392    };
1393    let mut out = Vec::with_capacity(num_tiles.min(payload.len()));
1394    for _ in 0..num_tiles {
1395        let min_b = take(&mut pos)?;
1396        let max_b = min_b.checked_add(take(&mut pos)?)?;
1397        let min_c = take(&mut pos)?;
1398        let max_c = min_c.checked_add(take(&mut pos)?)?;
1399        out.push((min_b, max_b, min_c, max_c));
1400    }
1401    Some(out)
1402}
1403
1404/// Parse a tiled section payload's directory (not the tiles). `bytes` may be a
1405/// **prefix** of the payload (a ranged reader fetches the directory before any
1406/// tile); tile byte ranges are validated against `total_len`, the full payload
1407/// length. Every length is untrusted.
1408fn parse_tile_directory(bytes: &[u8], total_len: u64) -> Result<Vec<TileDirEntry>, FileError> {
1409    let mut pos = 0usize;
1410    let take = |pos: &mut usize| -> Result<u64, FileError> {
1411        let (v, n) = read_uvarint(bytes.get(*pos..).unwrap_or(&[]))
1412            .ok_or(FileError::Container("truncated tile directory"))?;
1413        *pos += n;
1414        Ok(v)
1415    };
1416    let num_tiles = take(&mut pos)? as usize;
1417    let mut entries = Vec::with_capacity(num_tiles.min(bytes.len()));
1418    let mut prev_min = 0u32;
1419    let mut lens = Vec::with_capacity(num_tiles.min(bytes.len()));
1420    for _ in 0..num_tiles {
1421        let dmin = take(&mut pos)? as u32;
1422        let span = take(&mut pos)? as u32;
1423        let len = take(&mut pos)?;
1424        let min_a = prev_min.wrapping_add(dmin);
1425        entries.push(TileDirEntry {
1426            min_a,
1427            max_a: min_a.wrapping_add(span),
1428            start: 0,
1429            end: 0,
1430        });
1431        lens.push(len);
1432        prev_min = min_a;
1433    }
1434    let mut start = pos as u64;
1435    for (e, len) in entries.iter_mut().zip(lens) {
1436        let end = start
1437            .checked_add(len)
1438            .filter(|&e| e <= total_len)
1439            .ok_or(FileError::Container("tile overruns section"))?;
1440        e.start = start;
1441        e.end = end;
1442        start = end;
1443    }
1444    Ok(entries)
1445}
1446
1447/// Fetch and parse a remote tiled section's directory: read a small prefix and
1448/// grow it geometrically until the directory parses, never fetching past the
1449/// section. A directory that still fails on the whole section is corrupt.
1450fn read_tile_directory_ranged<R: RangeReader>(
1451    reader: &R,
1452    section: ByteRange,
1453) -> Result<Vec<TileDirEntry>, FileError> {
1454    let total = section.len;
1455    let mut prefetch = 4096u64.min(total);
1456    loop {
1457        let prefix = reader.read_at(section.offset, prefetch)?;
1458        match parse_tile_directory(&prefix, total) {
1459            Ok(dir) => return Ok(dir),
1460            Err(_) if prefetch < total => prefetch = prefetch.saturating_mul(2).min(total),
1461            Err(e) => return Err(e),
1462        }
1463    }
1464}
1465
1466/// Fetch and parse a remote section's **tile-synopsis trailer** (only when the
1467/// header's [`FLAG_TILE_SYNOPSIS`] is set): one targeted range read of the bytes
1468/// past the last tile, parsed into one synopsis per tile (directory order). A
1469/// missing/short/garbled trailer degrades to all-`None` — pruning simply doesn't
1470/// fire, never a wrong result. The directory gives the trailer's start (the last
1471/// tile's end).
1472fn read_tile_synopsis_ranged<R: RangeReader>(
1473    reader: &R,
1474    section: ByteRange,
1475    dir: &[TileDirEntry],
1476) -> Vec<Option<TileSynopsis>> {
1477    let n = dir.len();
1478    let none = vec![None; n];
1479    let trailer_start = dir.iter().map(|e| e.end).max().unwrap_or(0);
1480    let total = section.len;
1481    if n == 0 || trailer_start >= total {
1482        return none; // no trailer bytes present
1483    }
1484    let trailer_len = total - trailer_start;
1485    let Ok(bytes) = reader.read_at(section.offset + trailer_start, trailer_len) else {
1486        return none;
1487    };
1488    match parse_tile_synopsis(&bytes, 0, n) {
1489        Some(v) => v.into_iter().map(Some).collect(),
1490        None => none,
1491    }
1492}
1493
1494/// Per-tile absolute file ranges of each permutation section, for provenance.
1495/// A malformed directory yields an empty section (provenance degrades, queries
1496/// are unaffected).
1497fn tile_file_ranges(
1498    index_bytes: &[u8],
1499    container_offset: u64,
1500    section_ranges: &[ByteRange; NUM_PERMS],
1501) -> [Vec<(u32, u32, ByteRange)>; NUM_PERMS] {
1502    let mut out: [Vec<(u32, u32, ByteRange)>; NUM_PERMS] = Default::default();
1503    for (section, range) in out.iter_mut().zip(section_ranges) {
1504        // A permutation the file does not carry has a zeroed range. Its
1505        // `offset - container_offset` underflows, and before this guard it
1506        // panicked the whole `open_ranged` path on the first lean file.
1507        if range.len == 0 || range.offset < container_offset {
1508            continue;
1509        }
1510        let start = (range.offset - container_offset) as usize;
1511        let Some(payload) = index_bytes.get(start..start + range.len as usize) else {
1512            continue;
1513        };
1514        if let Ok(dir) = parse_tile_directory(payload, payload.len() as u64) {
1515            *section = dir
1516                .into_iter()
1517                .map(|e| {
1518                    (
1519                        e.min_a,
1520                        e.max_a,
1521                        ByteRange {
1522                            offset: range.offset + e.start,
1523                            len: (e.end - e.start),
1524                        },
1525                    )
1526                })
1527                .collect();
1528        }
1529    }
1530    out
1531}
1532
1533/// Decode a tiled section payload into `(min_a, max_a, uncompressed tile)`
1534/// triples.
1535fn decode_tiled_section(payload: &[u8], codec: u8) -> Result<Vec<(u32, u32, Vec<u8>)>, FileError> {
1536    parse_tile_directory(payload, payload.len() as u64)?
1537        .into_iter()
1538        .map(|e| {
1539            Ok((
1540                e.min_a,
1541                e.max_a,
1542                decompress(codec, &payload[e.start as usize..e.end as usize])?,
1543            ))
1544        })
1545        .collect()
1546}
1547
1548/// Decode the index container: one raw tiled section payload per permutation
1549/// the file carries (`perms`, from the header — [`PermSet::ALL`] for every file
1550/// written before the mask existed), each tile compressed individually.
1551///
1552/// The section count is checked against `perms.len()`, so a file whose header
1553/// and container disagree is rejected rather than silently short-read. A reader
1554/// that does not know about lean files at all reaches the same conclusion by a
1555/// different route: it passes six, the container says three, and this errors —
1556/// which is why a lean file cannot be misread as an empty one.
1557fn decode_index_container(
1558    bytes: &[u8],
1559    codec: u8,
1560    perms: PermSet,
1561) -> Result<GraphIndex, FileError> {
1562    let isecs = decode_container(bytes, CODEC_NONE)?;
1563    if isecs.len() != perms.len() {
1564        return Err(FileError::Container(
1565            "index container section count does not match the header permutation mask",
1566        ));
1567    }
1568    decode_index_sections(isecs, codec, perms)
1569}
1570
1571fn decode_index_sections(
1572    mut isecs: Vec<Vec<u8>>,
1573    codec: u8,
1574    perms: PermSet,
1575) -> Result<GraphIndex, FileError> {
1576    let mut sections: [Vec<(u32, u32, Vec<u8>)>; NUM_PERMS] = Default::default();
1577    for (perm, sec) in perms.iter().zip(isecs.iter_mut()) {
1578        sections[perm.section_index()] = decode_tiled_section(sec, codec)?;
1579    }
1580    Ok(GraphIndex::from_tiles(sections, perms))
1581}
1582
1583/// Decode the file's **root** index container (the default graph): the
1584/// permutation sections, plus — on a quad-index file — the trailing seal, which
1585/// is checked and set aside. Returns the seal's digest of the QUAD_INDEX
1586/// section when there is one.
1587fn decode_root_index_container(
1588    bytes: &[u8],
1589    header: &Header,
1590) -> Result<(GraphIndex, Option<[u8; 16]>), FileError> {
1591    if !header.has_quad_index() {
1592        return Ok((
1593            decode_index_container(bytes, header.block_codec, header.perms)?,
1594            None,
1595        ));
1596    }
1597    let mut isecs = decode_container(bytes, CODEC_NONE)?;
1598    if isecs.len() != header.index_container_sections() {
1599        return Err(FileError::Container(
1600            "index container section count does not match the header permutation mask",
1601        ));
1602    }
1603    let seal = isecs.pop().unwrap_or_default();
1604    let digest = crate::quad::parse_seal(&seal)?;
1605    Ok((
1606        decode_index_sections(isecs, header.block_codec, header.perms)?,
1607        Some(digest),
1608    ))
1609}
1610
1611/// The named-graph layout a header announces must be the one its directory
1612/// holds: the quad flag with a QUAD_INDEX section and no NAMED_GRAPHS section,
1613/// or no flag and no QUAD_INDEX section. Anything else is a corrupt (or a
1614/// hand-edited) header, refused before either layout is read.
1615fn check_named_layout(header: &Header) -> Result<(), FileError> {
1616    let quad = header.quad_index_section().is_some();
1617    if header.has_quad_index() {
1618        if !quad {
1619            return Err(FileError::Container(
1620                "quad-index flag set but the file has no quad-index section",
1621            ));
1622        }
1623        if header.named_graphs_len > 0 {
1624            return Err(FileError::Container(
1625                "a file cannot carry both a quad index and a named-graphs section",
1626            ));
1627        }
1628        crate::quad::check_perms(header.perms)?;
1629    } else if quad {
1630        return Err(FileError::Container(
1631            "quad-index section present but the header does not flag it",
1632        ));
1633    }
1634    Ok(())
1635}
1636
1637fn container_section_payload_ranges(
1638    bytes: &[u8],
1639    container_offset: u64,
1640    expected_sections: usize,
1641) -> Result<Vec<ByteRange>, FileError> {
1642    let (section_count, mut pos) =
1643        read_uvarint(bytes).ok_or(FileError::Container("truncated count"))?;
1644    let section_count = usize::try_from(section_count)
1645        .map_err(|_| FileError::Container("section count too large"))?;
1646    if section_count != expected_sections {
1647        return Err(FileError::Container("unexpected section count"));
1648    }
1649
1650    let mut ranges = Vec::with_capacity(section_count);
1651    for _ in 0..section_count {
1652        let remaining = bytes
1653            .get(pos..)
1654            .ok_or(FileError::Container("truncated length"))?;
1655        let (payload_len, used) =
1656            read_uvarint(remaining).ok_or(FileError::Container("truncated length"))?;
1657        pos = pos
1658            .checked_add(used)
1659            .ok_or(FileError::Container("section range overflows"))?;
1660        let payload_len_usize = usize::try_from(payload_len)
1661            .map_err(|_| FileError::Container("section length too large"))?;
1662        let payload_end = pos
1663            .checked_add(payload_len_usize)
1664            .ok_or(FileError::Container("section range overflows"))?;
1665        if payload_end > bytes.len() {
1666            return Err(FileError::Container("section overruns buffer"));
1667        }
1668        ranges.push(ByteRange {
1669            offset: checked_end(container_offset, pos as u64)?,
1670            len: payload_len,
1671        });
1672        pos = payload_end;
1673    }
1674
1675    Ok(ranges)
1676}
1677
1678/// The permutation payload ranges of an index container holding `sections`
1679/// sections (the permutations, then — on a quad-index root — the seal, which
1680/// is not a permutation and is left out).
1681fn decode_index_section_ranges(
1682    bytes: &[u8],
1683    container_offset: u64,
1684    perms: PermSet,
1685    sections: usize,
1686) -> Result<[ByteRange; NUM_PERMS], FileError> {
1687    let ranges = container_section_payload_ranges(bytes, container_offset, sections)?;
1688    let mut out = [ByteRange { offset: 0, len: 0 }; NUM_PERMS];
1689    for (perm, range) in perms.iter().zip(ranges) {
1690        out[perm.section_index()] = range;
1691    }
1692    Ok(out)
1693}
1694
1695fn read_uvarint_at<R: RangeReader + ?Sized>(
1696    reader: &R,
1697    absolute_offset: u64,
1698    container_end: u64,
1699) -> Result<(u64, u64), FileError> {
1700    if absolute_offset >= container_end {
1701        return Err(FileError::Container("truncated container varint"));
1702    }
1703    let remaining = container_end - absolute_offset;
1704    let probe_len = remaining.min(10);
1705    let bytes = reader.read_at(absolute_offset, probe_len)?;
1706    read_uvarint(&bytes)
1707        .map(|(value, used)| (value, used as u64))
1708        .ok_or(FileError::Container("truncated container varint"))
1709}
1710
1711/// Locate one section's payload byte range inside a remote container, walking
1712/// only the (tiny) varint framing — no payload bytes are fetched.
1713fn locate_container_section_ranged<R: RangeReader + ?Sized>(
1714    reader: &R,
1715    container_offset: u64,
1716    container_len: u64,
1717    section_index: usize,
1718    expected_sections: u64,
1719) -> Result<ByteRange, FileError> {
1720    let container_end = checked_end(container_offset, container_len)?;
1721    let (section_count, used) = read_uvarint_at(reader, container_offset, container_end)?;
1722    if section_count != expected_sections {
1723        return Err(FileError::Container("unexpected container section count"));
1724    }
1725    if section_index >= section_count as usize {
1726        return Err(FileError::Container(
1727            "container section index out of bounds",
1728        ));
1729    }
1730
1731    let mut pos = checked_end(container_offset, used)?;
1732    for i in 0..section_count as usize {
1733        let (payload_len, len_used) = read_uvarint_at(reader, pos, container_end)?;
1734        pos = checked_end(pos, len_used)?;
1735        let payload_end = checked_end(pos, payload_len)?;
1736        if payload_end > container_end {
1737            return Err(FileError::Container("section overruns buffer"));
1738        }
1739        if i == section_index {
1740            return Ok(ByteRange {
1741                offset: pos,
1742                len: payload_len,
1743            });
1744        }
1745        pos = payload_end;
1746    }
1747    Err(FileError::Container("container section not found"))
1748}
1749
1750/// Open one index container (six tiled permutation sections) **lazily** over a
1751/// range reader: fetch each section's tile directory (and synopsis trailer,
1752/// when the file has one) but no tile payloads — tiles fault in on first scan.
1753/// Returns the index plus its section and tile file ranges (provenance).
1754///
1755/// This is the machinery `open_ranged_lazy` always used for the default
1756/// graph's container at `root_dir_offset`; a named graph's container is the
1757/// same format at a different offset, so the lazy named-graphs path opens a
1758/// LARGE graph through this too instead of decoding it resident.
1759#[allow(clippy::type_complexity)]
1760fn open_index_container_lazy(
1761    reader: &std::sync::Arc<dyn RangeReader + Send + Sync>,
1762    container: ByteRange,
1763    block_codec: u8,
1764    has_synopsis: bool,
1765    read_concurrency: usize,
1766    perms: PermSet,
1767    sections: usize,
1768) -> Result<
1769    (
1770        GraphIndex,
1771        [ByteRange; NUM_PERMS],
1772        [Vec<(u32, u32, ByteRange)>; NUM_PERMS],
1773    ),
1774    FileError,
1775> {
1776    let mut index_section_ranges = [ByteRange { offset: 0, len: 0 }; NUM_PERMS];
1777    let mut tile_ranges: [Vec<(u32, u32, ByteRange)>; NUM_PERMS] = Default::default();
1778    #[allow(clippy::type_complexity)]
1779    let mut directories: [Vec<(u32, u32, Option<TileSynopsis>)>; NUM_PERMS] = Default::default();
1780    for (pos, perm) in perms.iter().enumerate() {
1781        let si = perm.section_index();
1782        let section = locate_container_section_ranged(
1783            reader,
1784            container.offset,
1785            container.len,
1786            pos,
1787            sections as u64,
1788        )?;
1789        index_section_ranges[si] = section;
1790        let dir = read_tile_directory_ranged(reader, section)?;
1791        // Tile synopses (one extra small tail read per section) let a routed
1792        // scan prune a tile by a bound secondary component before faulting it.
1793        let syn = if has_synopsis {
1794            read_tile_synopsis_ranged(reader, section, &dir)
1795        } else {
1796            vec![None; dir.len()]
1797        };
1798        directories[si] = dir
1799            .iter()
1800            .zip(syn)
1801            .map(|(e, s)| (e.min_a, e.max_a, s))
1802            .collect();
1803        tile_ranges[si] = dir
1804            .into_iter()
1805            .map(|e| {
1806                (
1807                    e.min_a,
1808                    e.max_a,
1809                    ByteRange {
1810                        offset: section.offset + e.start,
1811                        len: (e.end - e.start),
1812                    },
1813                )
1814            })
1815            .collect();
1816    }
1817
1818    // The loader fetches and decompresses one tile per call; the bulk
1819    // loader serves multi-tile scans by coalescing adjacent tile ranges
1820    // into single range reads (tiles are back-to-back in their section,
1821    // so a full-section scan is typically one request).
1822    let codec = block_codec;
1823    let loader_ranges = tile_ranges.clone();
1824    let loader_reader = reader.clone();
1825    let loader: crate::index::TileLoader = Box::new(move |si, ti| {
1826        let (_, _, range) = loader_ranges.get(si)?.get(ti)?;
1827        let bytes = loader_reader.read_at(range.offset, range.len).ok()?;
1828        decompress(codec, &bytes).ok()
1829    });
1830    let bulk_ranges = tile_ranges.clone();
1831    let bulk_reader = reader.clone();
1832    let bulk: crate::index::TileBulkLoader = Box::new(move |si, tis| {
1833        let section = bulk_ranges.get(si)?;
1834        let want: Option<Vec<ByteRange>> = tis
1835            .iter()
1836            .map(|&ti| section.get(ti).map(|&(_, _, r)| r))
1837            .collect();
1838        let blobs = read_coalesced(bulk_reader.as_ref(), &want?, TILE_COALESCE_GAP)?;
1839        blobs.iter().map(|b| decompress(codec, b).ok()).collect()
1840    });
1841    let mut index =
1842        GraphIndex::from_remote_directories(directories, perms, loader).with_bulk_loader(bulk);
1843    // Per-tile encoded lengths (from the directory) feed the join planner's
1844    // fatness gates — free here, unavailable later without a fetch.
1845    index.set_tile_lens(std::array::from_fn(|si| {
1846        tile_ranges[si]
1847            .iter()
1848            .map(|&(_, _, r)| r.len.min(u32::MAX as u64) as u32)
1849            .collect()
1850    }));
1851    // The reader's fan-out widens the planner's remote probe budget: a
1852    // desktop/CLI reader overlapping 16 range reads probes far more cheaply
1853    // than a phone's serial sync-XHR path.
1854    index.set_read_concurrency(read_concurrency);
1855    Ok((index, index_section_ranges, tile_ranges))
1856}
1857
1858/// Serialize a complete `.rete` file image from a dictionary, index, and an
1859/// (optionally empty) encoded pyramid-meta section. `pyramid_levels` records the
1860/// number of dendrogram rounds the pyramid spans (0 if no pyramid).
1861pub fn write_file(
1862    dict: &Dictionary,
1863    index: &GraphIndex,
1864    has_quads: bool,
1865    pyramid_meta: &[u8],
1866    pyramid_levels: u16,
1867) -> Vec<u8> {
1868    write_dataset(dict, index, &[], has_quads, pyramid_meta, pyramid_levels)
1869}
1870
1871/// Encode an index container (v0.2): one raw tiled section payload per
1872/// permutation the index carries, in [`crate::index::ALL_PERMS`] order, tiles
1873/// compressed individually with `codec`. A three-permutation index writes three
1874/// sections — not six with three empty, which would be indistinguishable from
1875/// an empty graph to a reader that does not check the header mask.
1876pub(crate) fn encode_index_container(index: &GraphIndex, codec: u8) -> Vec<u8> {
1877    encode_index_container_sealed(index, codec, None)
1878}
1879
1880/// [`encode_index_container`] with an optional trailing **seal** section —
1881/// the root container of a quad-index file (see `quad.rs`). The seal is what
1882/// makes every reader that predates the quad index refuse the file: they all
1883/// check the container's section count before reading a payload.
1884fn encode_index_container_sealed(index: &GraphIndex, codec: u8, seal: Option<&[u8]>) -> Vec<u8> {
1885    let payloads: Vec<Vec<u8>> = index
1886        .perms()
1887        .iter()
1888        .map(|perm| encode_tiled_section(index, perm.section_index(), codec))
1889        .collect();
1890    let mut refs: Vec<&[u8]> = payloads.iter().map(|p| p.as_slice()).collect();
1891    if let Some(seal) = seal {
1892        refs.push(seal);
1893    }
1894    encode_container(&refs, CODEC_NONE)
1895}
1896
1897/// Encode the named-graphs section: each graph as `(iri, permutation container)`.
1898fn encode_named_graphs(named: &[(String, GraphIndex)], codec: u8) -> Vec<u8> {
1899    let mut out = Vec::new();
1900    write_uvarint(&mut out, named.len() as u64);
1901    for (iri, index) in named {
1902        write_uvarint(&mut out, iri.len() as u64);
1903        out.extend_from_slice(iri.as_bytes());
1904        let container = encode_index_container(index, codec);
1905        write_uvarint(&mut out, container.len() as u64);
1906        out.extend_from_slice(&container);
1907    }
1908    out
1909}
1910
1911fn decode_named_graphs(
1912    bytes: &[u8],
1913    codec: u8,
1914    perms: PermSet,
1915) -> Result<Vec<(String, GraphIndex)>, FileError> {
1916    let (n, mut pos) = read_uvarint(bytes).ok_or(FileError::Container("truncated graph count"))?;
1917    // Bounds-checked slice within this (already bounded) section. Lengths read
1918    // below are untrusted, so every range is validated before indexing.
1919    let bound = |start: usize, len: u64| -> Result<usize, FileError> {
1920        start
1921            .checked_add(len as usize)
1922            .filter(|&e| e <= bytes.len())
1923            .ok_or(FileError::Container("named-graph field overruns buffer"))
1924    };
1925    let mut out = Vec::with_capacity((n as usize).min(bytes.len()));
1926    for _ in 0..n {
1927        let (ilen, u1) = read_uvarint(bytes.get(pos..).unwrap_or(&[]))
1928            .ok_or(FileError::Container("truncated iri len"))?;
1929        pos += u1;
1930        let iend = bound(pos, ilen)?;
1931        let iri = String::from_utf8_lossy(&bytes[pos..iend]).into_owned();
1932        pos = iend;
1933        let (clen, u2) = read_uvarint(bytes.get(pos..).unwrap_or(&[]))
1934            .ok_or(FileError::Container("truncated container len"))?;
1935        pos += u2;
1936        let cend = bound(pos, clen)?;
1937        let index = decode_index_container(&bytes[pos..cend], codec, perms)?;
1938        out.push((iri, index));
1939        pos = cend;
1940    }
1941    Ok(out)
1942}
1943
1944/// Serialize a full RDF *dataset*: the default-graph index plus zero or more
1945/// named graphs `(iri, index)`, all sharing one dictionary.
1946pub fn write_dataset(
1947    dict: &Dictionary,
1948    default_index: &GraphIndex,
1949    named: &[(String, GraphIndex)],
1950    has_quads: bool,
1951    pyramid_meta: &[u8],
1952    pyramid_levels: u16,
1953) -> Vec<u8> {
1954    write_dataset_with_metadata(
1955        dict,
1956        default_index,
1957        named,
1958        has_quads,
1959        pyramid_meta,
1960        pyramid_levels,
1961        &[],
1962        &[],
1963    )
1964}
1965
1966/// Serialize a dictionary to its on-file container bytes (4 front-coded, chunked
1967/// sections). Exposed so a low-RAM build can serialize **and drop** the live
1968/// `Dictionary` before building the permutation index — the index build works on
1969/// id-triples and never needs the dictionary.
1970pub(crate) fn encode_dict_container(dict: &Dictionary, codec: u8) -> Vec<u8> {
1971    let raw_sections = dict.sections();
1972    let dict_payloads: Vec<Vec<u8>> = raw_sections
1973        .iter()
1974        .map(|raw| encode_chunked_dict_section(raw, codec))
1975        .collect();
1976    encode_container(
1977        &[
1978            dict_payloads[0].as_slice(),
1979            dict_payloads[1].as_slice(),
1980            dict_payloads[2].as_slice(),
1981            dict_payloads[3].as_slice(),
1982        ],
1983        CODEC_NONE,
1984    )
1985}
1986
1987/// Serialize a dataset with an opaque **metadata** payload occupying the file's
1988/// metadata section (the application layer defines its meaning — the CLI stores a
1989/// JSON Dataset Card there). The section sits immediately after the header and
1990/// before the dictionary, so `metadata_offset` stays at `HEADER_LEN` and every
1991/// downstream section shifts by `metadata.len()`. The payload is folded into the
1992/// `content_hash`, so `verify` covers it and it is tamper-evident.
1993///
1994/// Passing an empty `metadata` is byte-identical to [`write_dataset`]: the section
1995/// is omitted (`metadata_len = 0`, `dictionary_offset = HEADER_LEN`) and the hash
1996/// is computed over exactly the same parts (a zero-length hash update is a no-op).
1997#[allow(clippy::too_many_arguments)]
1998pub fn write_dataset_with_metadata(
1999    dict: &Dictionary,
2000    default_index: &GraphIndex,
2001    named: &[(String, GraphIndex)],
2002    has_quads: bool,
2003    pyramid_meta: &[u8],
2004    pyramid_levels: u16,
2005    metadata: &[u8],
2006    text_index: &[u8],
2007) -> Vec<u8> {
2008    let codec = writer_codec();
2009    let dict_container = encode_dict_container(dict, codec);
2010    // This entry point takes a finished section, so it reads the section's own
2011    // scope block to pick the directory kind (6, or 11 for a partial index) and
2012    // the header's named-graphs bit.
2013    let scope = (!text_index.is_empty())
2014        .then(|| crate::text_index::TextIndex::from_section(text_index, codec).ok())
2015        .flatten()
2016        .map(|ti| ti.scope().clone());
2017    let text_index_partial = scope
2018        .as_ref()
2019        .is_some_and(crate::text_index::TextIndexScope::restricts_predicates);
2020    let text_index_named = scope
2021        .as_ref()
2022        .is_some_and(crate::text_index::TextIndexScope::covers_named_graphs);
2023    write_dataset_from_parts(
2024        &dict_container,
2025        dict.term_count() as u64,
2026        default_index,
2027        NamedPart::PerGraph(named),
2028        has_quads,
2029        dict.has_quoted_triples(),
2030        pyramid_meta,
2031        pyramid_levels,
2032        metadata,
2033        text_index,
2034        text_index_partial,
2035        text_index_named,
2036        codec,
2037    )
2038}
2039
2040/// How the named graphs reach the writer: one index per graph (the
2041/// NAMED_GRAPHS section, kind 5), or an encoded quad index (kind 10).
2042pub(crate) enum NamedPart<'a> {
2043    PerGraph(&'a [(String, GraphIndex)]),
2044    Quad(&'a crate::quad::EncodedQuadIndex),
2045}
2046
2047/// Assemble the final file image from an **already-serialized** dictionary
2048/// container (so the caller can drop the live `Dictionary` before calling this)
2049/// plus the permutation index and optional sections. The byte output is identical
2050/// to serializing the dictionary inline.
2051#[allow(clippy::too_many_arguments)]
2052pub(crate) fn write_dataset_from_parts(
2053    dict_container: &[u8],
2054    term_count: u64,
2055    default_index: &GraphIndex,
2056    named: NamedPart<'_>,
2057    has_quads: bool,
2058    has_quoted_triples: bool,
2059    pyramid_meta: &[u8],
2060    pyramid_levels: u16,
2061    metadata: &[u8],
2062    text_index: &[u8],
2063    text_index_partial: bool,
2064    text_index_named: bool,
2065    codec: u8,
2066) -> Vec<u8> {
2067    // The named graphs: either the per-graph section (kind 5) or the quad index
2068    // (kind 10), whose digest the root container's seal carries. An empty quad
2069    // index is written as no named graphs at all.
2070    let (named_section, quad, named_quads) = match named {
2071        NamedPart::PerGraph(named) => (
2072            if named.is_empty() {
2073                Vec::new()
2074            } else {
2075                encode_named_graphs(named, codec)
2076            },
2077            None,
2078            named
2079                .iter()
2080                .map(|(_, idx)| idx.triple_count() as u64)
2081                .sum::<u64>(),
2082        ),
2083        NamedPart::Quad(q) if q.graph_count == 0 => (Vec::new(), None, 0),
2084        NamedPart::Quad(q) => (Vec::new(), Some(q), q.quad_count),
2085    };
2086    let seal = quad.map(|q| crate::quad::encode_seal(crate::quad::digest(&q.bytes)));
2087    let index_container = encode_index_container_sealed(default_index, codec, seal.as_deref());
2088    // A predicate-restricted index (kind 11) is outside the content hash, like
2089    // kinds 8 and 9, and starts with its own digest instead: a reader older
2090    // than 0.4.0 does not see kind 11, so a hashed one would make its `verify`
2091    // call every such file corrupt. A kind-6 index is hashed, as it always was.
2092    let text_index_partial = text_index_partial && !text_index.is_empty();
2093    let digested;
2094    let text_index: &[u8] = if text_index_partial {
2095        digested = crate::text_index::with_partial_digest(text_index);
2096        &digested
2097    } else {
2098        text_index
2099    };
2100
2101    // The metadata section (if any) sits between the header and the dictionary,
2102    // so the dictionary — and everything after it — shifts forward by its length.
2103    let meta_section_len = metadata.len() as u64;
2104    let dict_offset = HEADER_LEN as u64 + meta_section_len;
2105    let dict_len = dict_container.len() as u64;
2106    let index_offset = dict_offset + dict_len;
2107    let index_len = index_container.len() as u64;
2108    let pyr_offset = index_offset + index_len;
2109    let pyr_len = pyramid_meta.len() as u64;
2110    // Optional full-text index between the pyramid and the named graphs.
2111    let text_offset = pyr_offset + pyr_len;
2112    let text_len = text_index.len() as u64;
2113    let named_offset = text_offset + text_len;
2114    let named_len = named_section.len() as u64;
2115    // The quad index sits where the named-graphs section would (the two are
2116    // exclusive).
2117    let quad_len = quad.map_or(0, |q| q.bytes.len() as u64);
2118
2119    // Hash parts in physical order, with the metadata payload prepended when
2120    // present. Omitting it entirely (rather than hashing an empty slice) keeps the
2121    // no-metadata output's hash byte-identical to the pre-metadata writer.
2122    // `verify()` rebuilds this exact list from the header — any section added
2123    // here must be added there too (and covered by a tamper test).
2124    let mut parts: Vec<&[u8]> = Vec::with_capacity(5);
2125    if meta_section_len > 0 {
2126        parts.push(metadata);
2127    }
2128    parts.push(dict_container);
2129    parts.push(&index_container);
2130    parts.push(pyramid_meta);
2131    if text_len > 0 && !text_index_partial {
2132        parts.push(text_index);
2133    }
2134    if named_len > 0 {
2135        parts.push(&named_section);
2136    }
2137
2138    // Length of the trailing schema-pyramid block (0 if none), so a reader can
2139    // fetch just that block for an index/dictionary/summary-free Tier-0 read.
2140    let schema_meta_len = crate::meta::schema_block_len(pyramid_meta);
2141
2142    // The QUAD_INDEX section is not a hash part of its own: the seal inside the
2143    // index container carries its digest, so the content hash covers it — and a
2144    // reader that predates it computes the same hash over the same parts.
2145    let header = Header {
2146        version: crate::header::CURRENT_FORMAT_VERSION,
2147        flags: FLAG_TILE_SYNOPSIS
2148            | if has_quads { FLAG_HAS_QUADS } else { 0 }
2149            | if quad.is_some() {
2150                crate::header::FLAG_QUAD_INDEX
2151            } else {
2152                0
2153            }
2154            | if has_quoted_triples {
2155                FLAG_HAS_QUOTED_TRIPLES
2156            } else {
2157                0
2158            }
2159            | if text_len > 0 && text_index_named {
2160                crate::header::FLAG_TEXT_INDEX_NAMED_GRAPHS
2161            } else {
2162                0
2163            },
2164        metadata_offset: HEADER_LEN as u64,
2165        metadata_len: meta_section_len,
2166        dictionary_offset: dict_offset,
2167        dictionary_len: dict_len,
2168        root_dir_offset: index_offset,
2169        root_dir_len: index_len,
2170        pyramid_meta_offset: if pyr_len > 0 { pyr_offset } else { 0 },
2171        pyramid_meta_len: pyr_len,
2172        dict_codec: codec,
2173        block_codec: codec,
2174        pyramid_levels,
2175        // Every container in the file — default graph and named graphs alike —
2176        // is written from an index built with the same set, so the mask is a
2177        // file-level fact and the default index is its source of truth.
2178        perms: default_index.perms(),
2179        quad_count: default_index.triple_count() as u64 + named_quads,
2180        term_count,
2181        content_hash: content_hash(&parts),
2182        named_graphs_offset: if named_len > 0 { named_offset } else { 0 },
2183        named_graphs_len: named_len,
2184        schema_meta_len,
2185        text_index_offset: if text_len > 0 { text_offset } else { 0 },
2186        text_index_len: text_len,
2187        text_index_partial: text_len > 0 && text_index_partial,
2188        build_info_offset: 0,
2189        build_info_len: 0,
2190        extra_sections: if quad_len > 0 {
2191            vec![crate::header::Section {
2192                kind: crate::header::SectionKind::QuadIndex,
2193                flags: 0,
2194                offset: named_offset,
2195                length: quad_len,
2196            }]
2197        } else {
2198            Vec::new()
2199        },
2200    };
2201
2202    let mut out = Vec::with_capacity(
2203        HEADER_LEN
2204            + metadata.len()
2205            + dict_container.len()
2206            + index_container.len()
2207            + pyramid_meta.len()
2208            + text_index.len()
2209            + named_section.len()
2210            + quad_len as usize
2211            + MAGIC.len(),
2212    );
2213    out.extend_from_slice(&header.to_bytes());
2214    if meta_section_len > 0 {
2215        out.extend_from_slice(metadata);
2216    }
2217    out.extend_from_slice(dict_container);
2218    out.extend_from_slice(&index_container);
2219    out.extend_from_slice(pyramid_meta);
2220    if text_len > 0 {
2221        out.extend_from_slice(text_index);
2222    }
2223    if named_len > 0 {
2224        out.extend_from_slice(&named_section);
2225    }
2226    if let Some(q) = quad {
2227        out.extend_from_slice(&q.bytes);
2228    }
2229    out.extend_from_slice(&MAGIC); // footer marker
2230    out
2231}
2232
2233/// `rdf:type` — the predicate that assigns a class to a resource.
2234pub const RDF_TYPE: &str = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>";
2235
2236/// An **ontology-aware** coarse graph: instead of structural communities, group
2237/// entities by their `rdf:type` class and aggregate relations between classes.
2238/// Returns `(subject_class, predicate, object_class, count)` over the default
2239/// graph. Entities with no type are `(untyped)`; literals are `(literal)`.
2240/// `rdf:type` triples themselves define the classes and are not counted as
2241/// relations. This is the dataset's effective schema with instance volumes.
2242pub fn schema_summary(rete: &Rete) -> Vec<(String, String, String, u32)> {
2243    use std::collections::{BTreeMap, HashMap};
2244    let triples = rete.dump(None);
2245
2246    let mut class_of: HashMap<&str, &str> = HashMap::new();
2247    for (s, p, o) in &triples {
2248        if p == RDF_TYPE {
2249            class_of.insert(s.as_str(), o.as_str());
2250        }
2251    }
2252    let classify = |t: &str| -> String {
2253        if let Some(c) = class_of.get(t) {
2254            (*c).to_string()
2255        } else if t.starts_with('"') {
2256            "(literal)".to_string()
2257        } else {
2258            "(untyped)".to_string()
2259        }
2260    };
2261
2262    let mut counts: BTreeMap<(String, String, String), u32> = BTreeMap::new();
2263    for (s, p, o) in &triples {
2264        if p == RDF_TYPE {
2265            continue; // type assertions define classes, not data relations
2266        }
2267        *counts
2268            .entry((classify(s), p.clone(), classify(o)))
2269            .or_default() += 1;
2270    }
2271    counts
2272        .into_iter()
2273        .map(|((a, p, b), c)| (a, p, b, c))
2274        .collect()
2275}
2276
2277/// Class populations: the number of resources of each `rdf:type` class in the
2278/// default graph, descending by count. The instance-count companion to
2279/// [`schema_summary`].
2280pub fn schema_classes(rete: &Rete) -> Vec<(String, u32)> {
2281    use std::collections::BTreeMap;
2282    let mut counts: BTreeMap<String, u32> = BTreeMap::new();
2283    for (_s, p, o) in rete.dump(None) {
2284        if p == RDF_TYPE {
2285            *counts.entry(o).or_default() += 1;
2286        }
2287    }
2288    let mut out: Vec<(String, u32)> = counts.into_iter().collect();
2289    out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
2290    out
2291}
2292
2293/// Fetch **only** the metadata section (the opaque Dataset Card blob) via a
2294/// [`RangeReader`]: read the 1 KiB header, then the metadata byte range —
2295/// nothing else. This is the index-free CARD tier of the exploration model: a
2296/// remote/S3 client learns the dataset's self-description in **two small range
2297/// requests**, never touching the dictionary, index, or pyramid. Returns `None`
2298/// when the file carries no metadata.
2299///
2300/// Companion to [`Rete::open_ranged`] (which deliberately *skips* the card to
2301/// keep the query path minimal); this is the explicit "I want the card" path.
2302pub fn read_metadata_ranged<R: RangeReader>(reader: &R) -> Result<Option<Vec<u8>>, FileError> {
2303    let head = reader.read_at(0, HEADER_LEN as u64)?;
2304    let header = Header::from_bytes(&head)?;
2305    if header.metadata_len == 0 {
2306        return Ok(None);
2307    }
2308    let bytes = reader.read_at(header.metadata_offset, header.metadata_len)?;
2309    Ok(Some(bytes))
2310}
2311
2312/// Fetch the metadata (Dataset Card) **and** build-info sections via a
2313/// [`RangeReader`] in the fewest requests: the 1 KiB header, then — because the
2314/// writer lays the build-info section immediately after the metadata — **one**
2315/// coalesced range covering both. A file with only one of the two costs the
2316/// same two requests; a file with neither costs just the header read. This
2317/// keeps "card + build conditions" within the CARD tier's 1 header + 1 range
2318/// budget instead of adding a third request.
2319///
2320/// Returns `(metadata, build_info)`, each `None` when absent.
2321#[allow(clippy::type_complexity)]
2322pub fn read_card_and_build_info_ranged<R: RangeReader>(
2323    reader: &R,
2324) -> Result<(Option<Vec<u8>>, Option<Vec<u8>>), FileError> {
2325    read_card_and_build_info_with_header(reader).map(|(_, m, b)| (m, b))
2326}
2327
2328/// [`read_card_and_build_info_ranged`], also returning the parsed [`Header`] it
2329/// had to read anyway.
2330///
2331/// The header is not a bonus: it carries the content hash and the section
2332/// directory — including whether the file has a TEXT_INDEX section, which the
2333/// card does not state (see
2334/// [`read_text_index_token_table_len_ranged`]). A caller that wants both and
2335/// used the tuple-only form would issue a **second** 1 KiB request for bytes it
2336/// already had, turning the CARD tier's documented "one header + one coalesced
2337/// range" into three requests.
2338#[allow(clippy::type_complexity)]
2339pub fn read_card_and_build_info_with_header<R: RangeReader>(
2340    reader: &R,
2341) -> Result<(Header, Option<Vec<u8>>, Option<Vec<u8>>), FileError> {
2342    let head = reader.read_at(0, HEADER_LEN as u64)?;
2343    let header = Header::from_bytes(&head)?;
2344    let meta = (header.metadata_offset, header.metadata_len);
2345    let build = (header.build_info_offset, header.build_info_len);
2346    if meta.1 > 0 && build.1 > 0 && build.0 == meta.0 + meta.1 {
2347        // Adjacent (the layout this crate writes): one read spans both.
2348        let both = reader.read_at(meta.0, meta.1 + build.1)?;
2349        let (m, b) = both.split_at(meta.1 as usize);
2350        return Ok((header, Some(m.to_vec()), Some(b.to_vec())));
2351    }
2352    let fetch = |off: u64, len: u64| -> Result<Option<Vec<u8>>, FileError> {
2353        if len == 0 {
2354            return Ok(None);
2355        }
2356        Ok(Some(reader.read_at(off, len)?))
2357    };
2358    Ok((header, fetch(meta.0, meta.1)?, fetch(build.0, build.1)?))
2359}
2360
2361/// The build-info section of a whole file image, or `None` if absent. The
2362/// payload is an opaque application-layer blob (the CLI stores build-conditions
2363/// JSON there); it is **not** covered by the content hash — see
2364/// [`attach_build_info`].
2365pub fn read_build_info(bytes: &[u8]) -> Result<Option<Vec<u8>>, FileError> {
2366    let header = Header::from_bytes(bytes)?;
2367    if header.build_info_len == 0 {
2368        return Ok(None);
2369    }
2370    let start = header.build_info_offset as usize;
2371    let end = start
2372        .checked_add(header.build_info_len as usize)
2373        .filter(|&e| e <= bytes.len())
2374        .ok_or(FileError::Container("build-info section overruns buffer"))?;
2375    Ok(Some(bytes[start..end].to_vec()))
2376}
2377
2378/// Everything needed to splice a build-info section into a finished file
2379/// **without holding the file in memory**: the rewritten header, where the
2380/// section goes, and where the bytes that do not move resume.
2381///
2382/// See [`plan_build_info`] for what the fields mean together: the build-info
2383/// section always follows the metadata section, so the layout is
2384/// `[header][metadata][build info][everything else, verbatim]` — or, for a
2385/// card placed after the data, `[header][data…][metadata][build info][footer]`.
2386#[derive(Debug, Clone, PartialEq, Eq)]
2387pub struct BuildInfoPlan {
2388    /// The header bytes to write at offset 0.
2389    pub header: [u8; HEADER_LEN],
2390    /// Offset the new section is written at: the end of the metadata section
2391    /// (the length of the unchanged prefix up to and including the card).
2392    pub insert: u64,
2393    /// Offset in the **old** file where the bytes that do not move resume. The
2394    /// span `insert..tail_start` is the old build-info section, if any, and is
2395    /// the only part of the old file the new one drops.
2396    pub tail_start: u64,
2397    /// Length of the file this plan produces.
2398    pub new_len: u64,
2399}
2400
2401/// Plan the splice of a build-info section of `info_len` bytes into a file
2402/// whose first [`HEADER_LEN`] bytes are `head` and whose total length is
2403/// `file_len`.
2404///
2405/// The section goes immediately after the metadata section — adjacent, so
2406/// [`read_card_and_build_info_ranged`] fetches card + build info in one
2407/// coalesced range — and every later section's offset shifts by the length
2408/// delta. That shift is the whole of the arithmetic, and it lives here so the
2409/// in-memory splice ([`attach_build_info`]) and any streaming rewriter derive
2410/// the same header from the same rule instead of each carrying a copy that can
2411/// drift.
2412///
2413/// The content hash is **deliberately left untouched**: build info records the
2414/// facts that differ between two builds of identical data (timestamp, builder,
2415/// measured costs), and folding them into the hash would break the
2416/// reproducible-hash property. `verify` accordingly ignores this section, on an
2417/// old reader (which sees an unknown kind-7 entry) and a new one alike — so a
2418/// file that gains, loses or changes a build record **keeps its content
2419/// identity**.
2420///
2421/// `info_len` of 0 plans the removal of an existing section.
2422pub fn plan_build_info(
2423    head: &[u8],
2424    file_len: u64,
2425    info_len: u64,
2426) -> Result<BuildInfoPlan, FileError> {
2427    let mut header = Header::from_bytes(head)?;
2428    // The insert point: immediately after the metadata section, wherever it
2429    // lies — at HEADER_LEN as the in-RAM writers place it, or after the data
2430    // where the memory-bounded build and `recard --in-place` put it
2431    // (`crate::recard::plan_card_append`). No card: right after the header.
2432    let insert = if header.metadata_len > 0 {
2433        header
2434            .metadata_offset
2435            .checked_add(header.metadata_len)
2436            .filter(|&end| header.metadata_offset >= HEADER_LEN as u64 && end <= file_len)
2437            .ok_or(FileError::Container(
2438                "the metadata section lies outside the file",
2439            ))?
2440    } else {
2441        HEADER_LEN as u64
2442    };
2443    let old_len = header.build_info_len;
2444    if old_len > 0 && header.build_info_offset != insert {
2445        return Err(FileError::Container(
2446            "existing build-info section is not adjacent to the metadata",
2447        ));
2448    }
2449    let tail_start = insert.saturating_add(old_len);
2450    if tail_start > file_len || insert > file_len {
2451        return Err(FileError::Container("build-info splice out of bounds"));
2452    }
2453
2454    // Shift every section that lives at or after the old tail.
2455    let shift = |off: &mut u64, len: u64| {
2456        if len > 0 && *off >= tail_start {
2457            *off = *off - old_len + info_len;
2458        }
2459    };
2460    shift(&mut header.dictionary_offset, header.dictionary_len);
2461    shift(&mut header.root_dir_offset, header.root_dir_len);
2462    shift(&mut header.pyramid_meta_offset, header.pyramid_meta_len);
2463    shift(&mut header.named_graphs_offset, header.named_graphs_len);
2464    shift(&mut header.text_index_offset, header.text_index_len);
2465    for s in &mut header.extra_sections {
2466        if s.length > 0 && s.offset >= tail_start {
2467            s.offset = s.offset - old_len + info_len;
2468        }
2469    }
2470    if info_len == 0 {
2471        header.build_info_offset = 0;
2472        header.build_info_len = 0;
2473    } else {
2474        header.build_info_offset = insert;
2475        header.build_info_len = info_len;
2476    }
2477
2478    Ok(BuildInfoPlan {
2479        header: header.to_bytes(),
2480        insert,
2481        tail_start,
2482        new_len: file_len - old_len + info_len,
2483    })
2484}
2485
2486/// Attach (or replace) a **build-info** section in a finished file image,
2487/// returning the new image. Layout and hash semantics: [`plan_build_info`].
2488///
2489/// Passing an empty `info` removes an existing section (or returns the image
2490/// unchanged when there is none).
2491pub fn attach_build_info(image: &[u8], info: &[u8]) -> Result<Vec<u8>, FileError> {
2492    let plan = plan_build_info(image, image.len() as u64, info.len() as u64)?;
2493    let mut out = Vec::with_capacity(plan.new_len as usize);
2494    out.extend_from_slice(&plan.header);
2495    out.extend_from_slice(&image[HEADER_LEN..plan.insert as usize]);
2496    out.extend_from_slice(info);
2497    out.extend_from_slice(&image[plan.tail_start as usize..]);
2498    debug_assert_eq!(out.len() as u64, plan.new_len);
2499    Ok(out)
2500}
2501
2502/// Replace the **metadata** section (the Dataset Card blob) of a finished file
2503/// image, returning the new image. The section is the first one after the
2504/// header, so every later section's offset shifts by the length delta.
2505///
2506/// Unlike [`attach_build_info`], the metadata payload **is** inside the content
2507/// hash — it is part of what the file says about itself — so the hash is
2508/// recomputed here. The result is byte-identical to what the writer would have
2509/// produced had it been handed this payload in the first place
2510/// (`write_dataset_with_metadata`): the section sits at `HEADER_LEN`, every
2511/// later offset is derived from its length, and the hash folds the same parts
2512/// in the same order.
2513///
2514/// Why splice rather than rebuild: the only caller that needs this is a build
2515/// that has already assembled the file and then learned something about it by
2516/// **running** its own starter queries against it. Re-deriving the whole image
2517/// to correct a metadata blob would mean re-parsing the input; a splice costs
2518/// one memcpy and one rehash of an image that is already resident.
2519///
2520/// Passing an empty payload removes the section.
2521pub fn replace_metadata(image: &[u8], metadata: &[u8]) -> Result<Vec<u8>, FileError> {
2522    let mut header = Header::from_bytes(image)?;
2523    // The writers in this crate always place metadata (when present) at
2524    // HEADER_LEN; anything else is a file this function must not reshape.
2525    let insert = HEADER_LEN as u64;
2526    if header.metadata_len > 0 && header.metadata_offset != insert {
2527        return Err(FileError::Container(
2528            "metadata section does not sit immediately after the header",
2529        ));
2530    }
2531    let old_len = header.metadata_len;
2532    let tail_start = (insert + old_len) as usize;
2533    if tail_start > image.len() {
2534        return Err(FileError::Container("metadata splice out of bounds"));
2535    }
2536    let new_len = metadata.len() as u64;
2537
2538    let shift = |off: &mut u64, len: u64| {
2539        if len > 0 && *off >= insert + old_len {
2540            *off = *off - old_len + new_len;
2541        }
2542    };
2543    shift(&mut header.dictionary_offset, header.dictionary_len);
2544    shift(&mut header.root_dir_offset, header.root_dir_len);
2545    shift(&mut header.pyramid_meta_offset, header.pyramid_meta_len);
2546    shift(&mut header.named_graphs_offset, header.named_graphs_len);
2547    shift(&mut header.text_index_offset, header.text_index_len);
2548    shift(&mut header.build_info_offset, header.build_info_len);
2549    for s in &mut header.extra_sections {
2550        if s.length > 0 && s.offset >= insert + old_len {
2551            s.offset = s.offset - old_len + new_len;
2552        }
2553    }
2554    header.metadata_offset = insert;
2555    header.metadata_len = new_len;
2556
2557    let mut out = Vec::with_capacity(image.len() - old_len as usize + metadata.len());
2558    out.extend_from_slice(&header.to_bytes());
2559    out.extend_from_slice(metadata);
2560    out.extend_from_slice(&image[tail_start..]);
2561    // The payload changed, so the hash must: recompute it over the same part
2562    // list `verify` will rebuild from this very header, then re-serialize the
2563    // header carrying it.
2564    header.content_hash = hash_of_sections(&header, &out)?;
2565    out[..HEADER_LEN].copy_from_slice(&header.to_bytes());
2566    Ok(out)
2567}
2568
2569/// The content hash of a file image, folded over exactly the parts the writer
2570/// hashed (see `write_dataset_from_parts`): the metadata payload when present,
2571/// then dict, index, pyramid-meta, and — when present — the text index and the
2572/// named graphs. [`verify`] compares this against the header; [`replace_metadata`]
2573/// writes it.
2574fn hash_of_sections(header: &Header, bytes: &[u8]) -> Result<[u8; 16], FileError> {
2575    let slice = |off: u64, len: u64| -> Result<&[u8], FileError> {
2576        // `off` and `len` come STRAIGHT FROM THE FILE HEADER, so a crafted or
2577        // corrupt .rete can set them near u64::MAX and make `off + len` overflow
2578        // — which panics in debug and wraps in release, BEFORE `.get()` gets the
2579        // chance to reject the range. Found by the weekly fuzz run
2580        // (fuzz_targets/open.rs, "attempt to add with overflow"). Saturating the
2581        // sum turns both cases into the same clean Container error.
2582        let end = off.saturating_add(len);
2583        bytes
2584            .get(off as usize..end as usize)
2585            .ok_or(FileError::Container("section overruns buffer"))
2586    };
2587    let parts = content_hash_parts(header)
2588        .into_iter()
2589        .map(|(off, len)| slice(off, len))
2590        .collect::<Result<Vec<&[u8]>, FileError>>()?;
2591    Ok(content_hash(&parts))
2592}
2593
2594/// The byte ranges the content hash folds, **in the order it folds them**: the
2595/// metadata payload when present, then dictionary, index, pyramid-meta, and —
2596/// when present — a kind-6 text index and the named graphs. Build info, the
2597/// self-digested kinds (8 LABEL_INDEX, 9 SCHEMA, 11 predicate-restricted text
2598/// index) and any section kind this reader does not know are deliberately
2599/// outside it. The quad index (kind 10) is not a range here either: its digest
2600/// is the seal at the end of the index container, so it is hashed by reference.
2601///
2602/// The one list [`verify`], [`verify_ranged`] and [`replace_metadata`] hash
2603/// over, and what a streaming rewriter (`rete recard`) feeds a hasher while it
2604/// copies — so a section a future format adds to the hash is added here, once,
2605/// and every one of them follows.
2606pub fn content_hash_parts(header: &Header) -> Vec<(u64, u64)> {
2607    let mut parts: Vec<(u64, u64)> = Vec::with_capacity(6);
2608    if header.metadata_len > 0 {
2609        parts.push((header.metadata_offset, header.metadata_len));
2610    }
2611    parts.push((header.dictionary_offset, header.dictionary_len));
2612    parts.push((header.root_dir_offset, header.root_dir_len));
2613    // A zero-length pyramid folds nothing either way; it is listed only when
2614    // present so the ranges a rewriter walks are all real sections.
2615    if header.pyramid_meta_len > 0 {
2616        parts.push((header.pyramid_meta_offset, header.pyramid_meta_len));
2617    }
2618    // A kind-11 (predicate-restricted) text index is self-digested instead.
2619    if header.text_index_len > 0 && !header.text_index_partial {
2620        parts.push((header.text_index_offset, header.text_index_len));
2621    }
2622    if header.named_graphs_len > 0 {
2623        parts.push((header.named_graphs_offset, header.named_graphs_len));
2624    }
2625    parts
2626}
2627
2628/// Recompute the content hash from a file image and check it against the header
2629/// — detects corruption or truncation of the payload sections. The
2630/// LABEL_INDEX, SCHEMA and predicate-restricted TEXT_INDEX sections (kinds 8,
2631/// 9, 11) are outside that hash — an older reader's `verify` must keep passing
2632/// on a file that carries them — so each is checked against its own leading
2633/// digest instead. The QUAD_INDEX section (kind 10) is hashed by reference: its
2634/// digest is the seal inside the (hashed) index container, checked here.
2635pub fn verify(bytes: &[u8]) -> Result<bool, FileError> {
2636    let header = Header::from_bytes(bytes)?;
2637    if hash_of_sections(&header, bytes)? != header.content_hash {
2638        return Ok(false);
2639    }
2640    // The QUAD_INDEX section is covered through the seal's digest (inside the
2641    // hashed index container), so it is checked against that.
2642    if header.has_quad_index() || header.quad_index_section().is_some() {
2643        check_named_layout(&header)?;
2644        let slice = |off: u64, len: u64| -> Result<&[u8], FileError> {
2645            bytes
2646                .get(off as usize..off.saturating_add(len) as usize)
2647                .ok_or(FileError::Container("section overruns buffer"))
2648        };
2649        let root = slice(header.root_dir_offset, header.root_dir_len)?;
2650        let ranges = container_section_payload_ranges(
2651            root,
2652            header.root_dir_offset,
2653            header.index_container_sections(),
2654        )?;
2655        let seal = ranges
2656            .last()
2657            .ok_or(FileError::Container("missing quad-index seal"))?;
2658        let want = crate::quad::parse_seal(slice(seal.offset, seal.len)?)?;
2659        let q = header
2660            .quad_index_section()
2661            .ok_or(FileError::Container("missing quad-index section"))?;
2662        if crate::quad::digest(slice(q.offset, q.length)?) != want {
2663            return Ok(false);
2664        }
2665    }
2666    for s in self_digested_sections(&header) {
2667        let end = s.offset.saturating_add(s.length);
2668        let section = bytes
2669            .get(s.offset as usize..end as usize)
2670            .ok_or(FileError::Container("section overruns buffer"))?;
2671        if !crate::label_index::digest_ok(section) {
2672            return Ok(false);
2673        }
2674    }
2675    Ok(true)
2676}
2677
2678/// The sections that carry their own leading 16-byte digest rather than being
2679/// folded into the header content hash: LABEL_INDEX (8), SCHEMA (9) and a
2680/// predicate-restricted TEXT_INDEX (11).
2681fn self_digested_sections(header: &Header) -> Vec<Section> {
2682    [
2683        header.label_index_section(),
2684        header.schema_section(),
2685        header
2686            .section(crate::header::SectionKind::TextIndexPartial)
2687            .filter(|s| s.length > 0),
2688    ]
2689    .into_iter()
2690    .flatten()
2691    .collect()
2692}
2693
2694/// Append LABEL_INDEX / SCHEMA sections (kinds 8, 9) to a finished file image,
2695/// just before its 4-byte footer, and return the new image.
2696///
2697/// They sit outside the content hash, so nothing before them moves and the
2698/// hash is unchanged: only the header's directory gains entries. An empty
2699/// payload is skipped. Each kind may be appended once; a second append of a
2700/// kind the file already carries is an error, as is any other kind.
2701pub fn append_sections(
2702    image: &[u8],
2703    sections: &[(SectionKind, &[u8])],
2704) -> Result<Vec<u8>, FileError> {
2705    let lens: Vec<(SectionKind, u64)> =
2706        sections.iter().map(|(k, b)| (*k, b.len() as u64)).collect();
2707    let (header, body_end) = plan_append(image, image.len() as u64, &lens)?;
2708    let extra: usize = sections.iter().map(|(_, b)| b.len()).sum();
2709    let mut out = Vec::with_capacity(image.len() + extra);
2710    out.extend_from_slice(&header.to_bytes());
2711    out.extend_from_slice(&image[HEADER_LEN..body_end as usize]);
2712    for (_, b) in sections {
2713        out.extend_from_slice(b);
2714    }
2715    out.extend_from_slice(&MAGIC);
2716    Ok(out)
2717}
2718
2719/// The header an append produces, and where the old footer starts.
2720fn plan_append(
2721    head: &[u8],
2722    file_len: u64,
2723    sections: &[(SectionKind, u64)],
2724) -> Result<(Header, u64), FileError> {
2725    let mut header = Header::from_bytes(head)?;
2726    if header.expected_file_len() != Some(file_len) {
2727        return Err(FileError::Container(
2728            "cannot append sections: the file length does not match its header",
2729        ));
2730    }
2731    let body_end = file_len - MAGIC.len() as u64;
2732    let mut at = body_end;
2733    for &(kind, len) in sections {
2734        if !matches!(kind, SectionKind::LabelIndex | SectionKind::Schema) {
2735            return Err(FileError::Container(
2736                "only LABEL_INDEX and SCHEMA sections can be appended",
2737            ));
2738        }
2739        if len == 0 {
2740            continue;
2741        }
2742        if header.section(kind).is_some_and(|s| s.length > 0) {
2743            return Err(FileError::Container(
2744                "the file already carries that section",
2745            ));
2746        }
2747        header = header.with_section(kind, at, len);
2748        at += len;
2749    }
2750    Ok((header, body_end))
2751}
2752
2753/// One piece of a section appended by [`append_sections_to_file`].
2754#[cfg(not(target_arch = "wasm32"))]
2755pub(crate) enum PayloadPart<'a> {
2756    /// Bytes in memory.
2757    Bytes(&'a [u8]),
2758    /// A whole file, streamed.
2759    File(&'a std::path::Path),
2760}
2761
2762/// [`append_sections`] on a file on disk, in place: the sections overwrite the
2763/// footer and are followed by a new one, then the header is rewritten. Each
2764/// section is the concatenation of its parts, so a large one streams from a
2765/// spill file. The memory-bounded build uses this after writing its output.
2766#[cfg(not(target_arch = "wasm32"))]
2767pub(crate) fn append_sections_to_file(
2768    path: &std::path::Path,
2769    sections: &[(SectionKind, Vec<PayloadPart<'_>>)],
2770) -> Result<(), FileError> {
2771    use std::io::{Read, Seek, SeekFrom, Write};
2772    let part_len = |p: &PayloadPart<'_>| -> std::io::Result<u64> {
2773        match p {
2774            PayloadPart::Bytes(b) => Ok(b.len() as u64),
2775            PayloadPart::File(f) => Ok(std::fs::metadata(f)?.len()),
2776        }
2777    };
2778    let mut lens = Vec::with_capacity(sections.len());
2779    for (kind, parts) in sections {
2780        let mut n = 0u64;
2781        for p in parts {
2782            n += part_len(p)?;
2783        }
2784        lens.push((*kind, n));
2785    }
2786    let mut f = std::fs::OpenOptions::new()
2787        .read(true)
2788        .write(true)
2789        .open(path)?;
2790    let len = f.metadata()?.len();
2791    let mut head = vec![0u8; HEADER_LEN];
2792    f.read_exact(&mut head)?;
2793    let (header, body_end) = plan_append(&head, len, &lens)?;
2794    f.seek(SeekFrom::Start(body_end))?;
2795    let mut w = std::io::BufWriter::with_capacity(1 << 20, &mut f);
2796    for (_, parts) in sections {
2797        for p in parts {
2798            match p {
2799                PayloadPart::Bytes(b) => w.write_all(b)?,
2800                PayloadPart::File(src) => {
2801                    std::io::copy(&mut std::fs::File::open(src)?, &mut w)?;
2802                }
2803            }
2804        }
2805    }
2806    w.write_all(&MAGIC)?;
2807    w.flush()?;
2808    drop(w);
2809    f.seek(SeekFrom::Start(0))?;
2810    f.write_all(&header.to_bytes())?;
2811    f.flush()?;
2812    Ok(())
2813}
2814
2815/// Open a file's dictionary over a [`RangeReader`] with only its chunk
2816/// directories resident — for the memory-bounded build, which resolves the
2817/// SCHEMA section's few class and predicate ids through the file it just
2818/// wrote.
2819#[cfg(not(target_arch = "wasm32"))]
2820pub(crate) fn open_dictionary_lazy<R: RangeReader + Send + Sync + 'static>(
2821    reader: R,
2822) -> Result<Dictionary, FileError> {
2823    let head = reader.read_at(0, HEADER_LEN as u64)?;
2824    let header = Header::from_bytes(&head)?;
2825    ranged_chunked_dictionary(&std::sync::Arc::new(reader), &header, [true; 4])
2826}
2827
2828/// [`verify`] over a [`RangeReader`](crate::reader::RangeReader), streaming:
2829/// the hashed sections are read in bounded slices and folded into one BLAKE3
2830/// state, so peak memory is one slice, not the file. The same sections, in
2831/// the same order, as `hash_of_sections`; on a 49 GB file the in-memory form
2832/// needs 49 GB.
2833pub fn verify_ranged(reader: &dyn crate::reader::RangeReader) -> Result<bool, FileError> {
2834    /// Read size per step: large enough that a local or remote read is
2835    /// throughput-bound, small enough to be negligible next to any budget.
2836    const SLICE: u64 = 8 << 20;
2837    let len = reader.len();
2838    if len < HEADER_LEN as u64 {
2839        return Err(FileError::Container("file shorter than its header"));
2840    }
2841    let header = Header::from_bytes(&reader.read_at(0, HEADER_LEN as u64)?)?;
2842    let mut h = blake3::Hasher::new();
2843    for (off, n) in content_hash_parts(&header) {
2844        // Offsets come straight from the header: reject an overrun (or an
2845        // overflowing sum) before reading, as `hash_of_sections` does.
2846        if off.checked_add(n).is_none_or(|end| end > len) {
2847            return Err(FileError::Container("section overruns buffer"));
2848        }
2849        let mut at = off;
2850        while at < off + n {
2851            let take = (off + n - at).min(SLICE);
2852            h.update(&reader.read_at(at, take)?);
2853            at += take;
2854        }
2855    }
2856    let mut out = [0u8; 16];
2857    out.copy_from_slice(&h.finalize().as_bytes()[..16]);
2858    if out != header.content_hash {
2859        return Ok(false);
2860    }
2861    // The QUAD_INDEX section, against the digest in the index container's seal
2862    // (see `verify`), streamed the same way.
2863    if header.has_quad_index() || header.quad_index_section().is_some() {
2864        check_named_layout(&header)?;
2865        let sections = header.index_container_sections();
2866        let seal = locate_container_section_ranged(
2867            reader,
2868            header.root_dir_offset,
2869            header.root_dir_len,
2870            sections - 1,
2871            sections as u64,
2872        )?;
2873        let want = crate::quad::parse_seal(&reader.read_at(seal.offset, seal.len)?)?;
2874        let q = header
2875            .quad_index_section()
2876            .ok_or(FileError::Container("missing quad-index section"))?;
2877        if q.offset.checked_add(q.length).is_none_or(|end| end > len) {
2878            return Err(FileError::Container("section overruns buffer"));
2879        }
2880        let mut h = blake3::Hasher::new();
2881        let mut at = q.offset;
2882        while at < q.offset + q.length {
2883            let take = (q.offset + q.length - at).min(SLICE);
2884            h.update(&reader.read_at(at, take)?);
2885            at += take;
2886        }
2887        let mut got = [0u8; 16];
2888        got.copy_from_slice(&h.finalize().as_bytes()[..16]);
2889        if got != want {
2890            return Ok(false);
2891        }
2892    }
2893    // The self-digested sections (kinds 8, 9, 11), streamed the same way.
2894    for s in self_digested_sections(&header) {
2895        if s.length < 16 || s.offset.checked_add(s.length).is_none_or(|end| end > len) {
2896            return Err(FileError::Container("section overruns buffer"));
2897        }
2898        let want = reader.read_at(s.offset, 16)?;
2899        let mut h = blake3::Hasher::new();
2900        let mut at = s.offset + 16;
2901        while at < s.offset + s.length {
2902            let take = (s.offset + s.length - at).min(SLICE);
2903            h.update(&reader.read_at(at, take)?);
2904            at += take;
2905        }
2906        if h.finalize().as_bytes()[..16] != want[..] {
2907            return Ok(false);
2908        }
2909    }
2910    Ok(true)
2911}
2912
2913/// Faults the pyramid meta in on first access. `None` = the fetch failed.
2914type PyramidLoader = Box<dyn Fn() -> Option<PyramidMeta> + Send + Sync>;
2915
2916/// The pyramid-meta section, held either resident (eager opens) or deferred
2917/// (the lazy remote open). SPARQL never touches the pyramid, but on a Wikidata
2918/// file it can be tens of MB (114k communities, millions of superedges), so a
2919/// remote SPARQL query must not pay to fetch it — it faults in only when a
2920/// community/pyramid query actually calls [`Rete::pyramid`].
2921enum PyramidSlot {
2922    Resident(Option<PyramidMeta>),
2923    Lazy {
2924        loader: PyramidLoader,
2925        cell: std::sync::OnceLock<Option<PyramidMeta>>,
2926    },
2927}
2928
2929/// A section held resident (eager opens) or faulted in on first use (lazy
2930/// opens) — the LABEL_INDEX and SCHEMA sections. `None` inside means the file
2931/// has no such section, or it could not be read or parsed: both are optional
2932/// and derived, so a bad one degrades to "absent" (and `verify` reports it).
2933pub(crate) enum SectionSlot<T> {
2934    Resident(Option<T>),
2935    Lazy {
2936        loader: Box<dyn Fn() -> Option<T> + Send + Sync>,
2937        cell: std::sync::OnceLock<Option<T>>,
2938    },
2939}
2940
2941impl<T> SectionSlot<T> {
2942    /// The section, faulting it in on the lazy path.
2943    pub(crate) fn get(&self) -> Option<&T> {
2944        match self {
2945            SectionSlot::Resident(v) => v.as_ref(),
2946            SectionSlot::Lazy { loader, cell } => cell.get_or_init(loader).as_ref(),
2947        }
2948    }
2949
2950    /// The section only if it is resident or already faulted — never a read.
2951    pub(crate) fn get_if_loaded(&self) -> Option<&T> {
2952        match self {
2953            SectionSlot::Resident(v) => v.as_ref(),
2954            SectionSlot::Lazy { cell, .. } => cell.get().and_then(|o| o.as_ref()),
2955        }
2956    }
2957}
2958
2959/// The LABEL_INDEX of a file image held in memory.
2960fn resident_label_slot<'a>(
2961    header: &Header,
2962    region: &dyn Fn(u64, u64) -> Result<&'a [u8], FileError>,
2963) -> SectionSlot<crate::label_index::LabelIndex> {
2964    SectionSlot::Resident(header.label_index_section().and_then(|s| {
2965        let bytes = region(s.offset, s.length).ok()?;
2966        crate::label_index::LabelIndex::from_section(bytes, header.block_codec).ok()
2967    }))
2968}
2969
2970/// The SCHEMA section of a file image held in memory.
2971fn resident_schema_slot<'a>(
2972    header: &Header,
2973    region: &dyn Fn(u64, u64) -> Result<&'a [u8], FileError>,
2974) -> SectionSlot<crate::schema_section::SchemaMeta> {
2975    SectionSlot::Resident(header.schema_section().and_then(|s| {
2976        crate::schema_section::SchemaMeta::decode(region(s.offset, s.length).ok()?).ok()
2977    }))
2978}
2979
2980/// The deferred LABEL_INDEX of a ranged open: the first search reads the
2981/// section head (preamble + root, ≤ [`crate::label_index::HEAD_READ`] bytes),
2982/// then one directory node per level and the data blocks it needs.
2983fn ranged_label_slot<R: RangeReader + Send + Sync + 'static>(
2984    reader: &std::sync::Arc<R>,
2985    header: &Header,
2986) -> SectionSlot<crate::label_index::LabelIndex> {
2987    let Some(s) = header.label_index_section() else {
2988        return SectionSlot::Resident(None);
2989    };
2990    let r = reader.clone();
2991    let codec = header.block_codec;
2992    SectionSlot::Lazy {
2993        loader: Box::new(move || {
2994            let head = r
2995                .read_at(s.offset, crate::label_index::HEAD_READ.min(s.length))
2996                .ok()?;
2997            let rr = r.clone();
2998            let loader = Box::new(move |off: u64, len: u64| rr.read_at(s.offset + off, len).ok());
2999            crate::label_index::LabelIndex::from_head(&head, s.length, codec, loader).ok()
3000        }),
3001        cell: std::sync::OnceLock::new(),
3002    }
3003}
3004
3005/// The deferred SCHEMA section of a ranged open: one range read, on first use.
3006fn ranged_schema_slot<R: RangeReader + Send + Sync + 'static>(
3007    reader: &std::sync::Arc<R>,
3008    header: &Header,
3009) -> SectionSlot<crate::schema_section::SchemaMeta> {
3010    let Some(s) = header.schema_section() else {
3011        return SectionSlot::Resident(None);
3012    };
3013    let r = reader.clone();
3014    SectionSlot::Lazy {
3015        loader: Box::new(move || {
3016            let bytes = r.read_at(s.offset, s.length).ok()?;
3017            crate::schema_section::SchemaMeta::decode(&bytes).ok()
3018        }),
3019        cell: std::sync::OnceLock::new(),
3020    }
3021}
3022
3023/// Read and decode a file's SCHEMA section over a [`RangeReader`] — the
3024/// header and one range. `Ok(None)` when the file has none (or it is
3025/// malformed: the section is optional and derived).
3026pub fn read_schema_ranged<R: RangeReader + ?Sized>(
3027    reader: &R,
3028) -> Result<Option<crate::schema_section::SchemaMeta>, FileError> {
3029    let head = reader.read_at(0, HEADER_LEN as u64)?;
3030    let header = Header::from_bytes(&head)?;
3031    Ok(read_schema_with_header(reader, &header))
3032}
3033
3034fn read_schema_with_header<R: RangeReader + ?Sized>(
3035    reader: &R,
3036    header: &Header,
3037) -> Option<crate::schema_section::SchemaMeta> {
3038    let s = header.schema_section()?;
3039    let bytes = reader.read_at(s.offset, s.length).ok()?;
3040    crate::schema_section::SchemaMeta::decode(&bytes).ok()
3041}
3042
3043/// Faults the text index in on first search. `None` = the file has none, or the
3044/// fetch/parse failed.
3045type TextIndexLoader = Box<dyn Fn() -> Option<crate::text_index::TextIndex> + Send + Sync>;
3046
3047/// Answers the token table's byte length from the section's first ≤10 bytes —
3048/// the length varint alone, never the table it measures.
3049type TokenTableProbe = Box<dyn Fn() -> Option<u64> + Send + Sync>;
3050
3051/// The TEXT_INDEX section, held either resident (eager opens decode the whole
3052/// thing) or deferred (the lazy remote open keeps only a loader that fetches the
3053/// token table on first search, then faults posting lists one at a time). SPARQL
3054/// never touches it, so the lazy remote path keeps its small range budget.
3055///
3056/// Both variants can also answer how long the section's leading **token table**
3057/// is without faulting it — the figure a caller needs to state what a first
3058/// search will cost, since `header.text_index_len` counts the postings blob too
3059/// and overstates it by several times on a literal-heavy graph.
3060enum TextIndexSlot {
3061    Resident {
3062        index: Option<crate::text_index::TextIndex>,
3063        /// Measured from the section bytes this open already held; `None` when
3064        /// the file carries no text index.
3065        token_table_len: Option<u64>,
3066    },
3067    Lazy {
3068        loader: TextIndexLoader,
3069        cell: std::sync::OnceLock<Option<crate::text_index::TextIndex>>,
3070        /// One ≤10-byte range read, memoized in `token_table_cell`.
3071        token_table: TokenTableProbe,
3072        token_table_cell: std::sync::OnceLock<Option<u64>>,
3073    },
3074}
3075
3076impl TextIndexSlot {
3077    /// The parsed index, faulting it in (token table first) on the lazy path.
3078    fn index(&self) -> Option<&crate::text_index::TextIndex> {
3079        match self {
3080            TextIndexSlot::Resident { index, .. } => index.as_ref(),
3081            TextIndexSlot::Lazy { loader, cell, .. } => cell.get_or_init(loader).as_ref(),
3082        }
3083    }
3084
3085    /// Byte length of the section's leading token table — see
3086    /// [`Rete::text_index_token_table_len`].
3087    fn token_table_len(&self) -> Option<u64> {
3088        match self {
3089            TextIndexSlot::Resident {
3090                token_table_len, ..
3091            } => *token_table_len,
3092            TextIndexSlot::Lazy {
3093                token_table,
3094                token_table_cell,
3095                ..
3096            } => *token_table_cell.get_or_init(token_table),
3097        }
3098    }
3099}
3100
3101/// Decode a whole TEXT_INDEX section held in memory, measuring its token table
3102/// on the way past. `section` is `None` when the file carries no text index.
3103fn resident_text_index_slot(section: Option<&[u8]>, codec: u8) -> Result<TextIndexSlot, FileError> {
3104    let Some(section) = section else {
3105        return Ok(TextIndexSlot::Resident {
3106            index: None,
3107            token_table_len: None,
3108        });
3109    };
3110    Ok(TextIndexSlot::Resident {
3111        index: Some(
3112            crate::text_index::TextIndex::from_section(section, codec)
3113                .map_err(|_| FileError::Container("malformed text index"))?,
3114        ),
3115        token_table_len: crate::text_index::TextIndex::postings_base(section).map(|b| b as u64),
3116    })
3117}
3118
3119/// Byte length of the TEXT_INDEX section's leading **token table**, from a
3120/// header a caller has already parsed — the prefix range a first
3121/// [`Rete::text_search`] fetches, and the honest figure to quote as its cost
3122/// (`header.text_index_len` counts the postings blob too and overstates it
3123/// several-fold). `None` when the file carries no text index, or its first
3124/// bytes could not be read.
3125///
3126/// Costs **one range read of ≤10 bytes** — the section's leading length varint
3127/// and nothing else, never the table it measures. This is the free-standing
3128/// companion to [`Rete::text_index_token_table_len`], for readers that hold a
3129/// [`Header`] and a [`RangeReader`] but never open the file (the CARD tier:
3130/// `rete card`, `rete card-url`).
3131pub fn read_text_index_token_table_len_ranged<R: RangeReader + ?Sized>(
3132    reader: &R,
3133    header: &Header,
3134) -> Option<u64> {
3135    let (ti_off, ti_len) = header.text_index_payload();
3136    if ti_len == 0 {
3137        return None;
3138    }
3139    read_token_table_len(reader, ti_off, ti_len)
3140}
3141
3142/// Byte length of the TEXT_INDEX section's leading token table — its length
3143/// varint plus the compressed table, i.e. the prefix range a first search
3144/// fetches. Reads the section's first ≤10 bytes and nothing else.
3145fn read_token_table_len<R: RangeReader + ?Sized>(reader: &R, off: u64, len: u64) -> Option<u64> {
3146    let head = reader.read_at(off, 10u64.min(len)).ok()?;
3147    let (ttlen, n) = crate::varint::read_uvarint(&head)?;
3148    Some((n as u64 + ttlen).min(len))
3149}
3150
3151/// A named graph's container decodes RESIDENT (one range read, one decode) up
3152/// to this size on the lazy path; a larger container opens as a remote-lazy
3153/// tile index instead, so one selective `GRAPH <g>` query over a multi-GB
3154/// graph faults only the tile directories plus the tiles it touches.
3155const NAMED_GRAPH_RESIDENT_MAX: u64 = 1 << 20; // 1 MiB
3156
3157/// Largest quad-index graph (in quads) a many-graph walk evaluates through an
3158/// owned, dropped view ([`Rete::named_graph_transient`]) rather than a memoised
3159/// one. A view is ~1–2 KB of descriptors whatever the graph's size, so keeping
3160/// one per graph is what grows with the graph count; past this size a graph is
3161/// worth memoising, and its rows are no longer worth materializing per graph.
3162pub const TRANSIENT_GRAPH_QUADS: u64 = 1024;
3163
3164/// Entries per lazily-allocated named-graph slab. The outer table is sized by
3165/// the section's leading count varint, which is UNTRUSTED — slabbing keeps a
3166/// hostile count from ballooning the allocation (the outer table costs a few
3167/// bytes per claimed slab; entry storage only materializes when touched).
3168const NAMED_SLAB: usize = 1024;
3169
3170/// First read of the named-graph walk, and the floor its ramp resets to. One
3171/// chunk covers a few dozen small-graph records, so a `LIMIT`ed `GRAPH ?g`
3172/// pays exactly one small read — the targeted-query win this size protects.
3173const NAMED_WALK_CHUNK: u64 = 64 * 1024;
3174
3175/// Ceiling of the walk's geometric read ramp — the read size an exhaustive
3176/// walk converges to (and starts at, when the demand is known exhaustive).
3177///
3178/// Sized from measurement, not taste. Against the nkod reference file
3179/// (67.9 MB, 31,974 named graphs, 65.4 MB section) the fixed 64 KiB walk cost
3180/// 262 requests / ~16.5 s in Chromium where the old eager whole-section fetch
3181/// cost ~8.5 s — i.e. ~31 ms of per-request overhead on ~8 s of transfer
3182/// (~7.7 MB/s). At 8 MiB per read a chunk transfers in ~1 s, putting the
3183/// request overhead near 3% of total time; halving the cap to 4 MiB doubles
3184/// that overhead, doubling the cap to 16 MiB buys back under 2% while doubling
3185/// both the walk buffer a wasm32 session must hold resident and the worst-case
3186/// over-read past the point a query stops. 8 MiB is the knee.
3187const NAMED_WALK_CHUNK_MAX: u64 = 8 * 1024 * 1024;
3188
3189/// One named graph, walked lazily out of the NAMED_GRAPHS section.
3190#[derive(Default)]
3191struct NamedEntry {
3192    /// `(iri, absolute byte range of the graph's index container)` — filled
3193    /// when the sequential directory walk reaches this slot.
3194    meta: std::sync::OnceLock<(String, ByteRange)>,
3195    /// The graph's permutation index, opened on first access. Boxed: most
3196    /// entries of a many-graph dataset are never touched by a given query.
3197    index: std::sync::OnceLock<Box<GraphIndex>>,
3198}
3199
3200/// Sequential position of the lazy named-graphs walk. The section has no
3201/// random-access directory — each entry's offset is the previous entry's end —
3202/// so entries are parsed strictly in order and memoised.
3203#[derive(Default)]
3204struct NamedWalk {
3205    /// Next entry index to parse.
3206    next: usize,
3207    /// Absolute file offset of that entry's header; `0` = the leading count
3208    /// varint has not been consumed yet (offset 0 is the file header, never
3209    /// inside a section).
3210    pos: u64,
3211    /// Read buffer carried ACROSS walk calls: `buf` holds the section bytes at
3212    /// `[buf_off, buf_off + buf.len())`. Entry-by-entry iteration (the
3213    /// `GRAPH ?g` path advances one entry per call) parses ~30 small-graph
3214    /// headers per 64 KiB chunk instead of re-reading a chunk per entry.
3215    /// [`LazyNamedGraphs::open_graph`] also serves whole small containers out
3216    /// of it, so a bulk chunk's ride-along payloads are never re-fetched.
3217    buf: Vec<u8>,
3218    buf_off: u64,
3219    /// Current read size of the geometric ramp (`0` = not started; treated as
3220    /// [`NAMED_WALK_CHUNK`]). Doubles on every buffer refill up to
3221    /// [`NAMED_WALK_CHUNK_MAX`], so a walk that keeps going converges to bulk
3222    /// reads after fetching at most as many bytes as it already used (the
3223    /// doubling bound: wasted read-ahead ≤ useful bytes). Reset to the floor
3224    /// whenever an oversized container is parsed — see `big_seen`.
3225    chunk: u64,
3226    /// A container larger than [`NAMED_GRAPH_RESIDENT_MAX`] has been parsed.
3227    /// Such graphs open tile-lazily (their payload bytes are NOT wanted by the
3228    /// walk), so ride-along prefetch stops paying off: the ramp resets and the
3229    /// exhaustive-demand hint stops seeding full-size reads. Without this, a
3230    /// header walk over a file of multi-MB containers would fetch the payloads
3231    /// it exists to hop over.
3232    big_seen: bool,
3233}
3234
3235/// The NAMED_GRAPHS section opened lazily over a [`RangeReader`]: nothing is
3236/// fetched at open. The section is a count varint followed by
3237/// `iri_len | iri | container_len | container` records, so the directory is
3238/// walked forward on demand — header bytes only, skipping container payloads —
3239/// and each touched graph's index is decoded (or opened tile-lazily) on first
3240/// access. A `LIMIT`ed `GRAPH ?g` query therefore reads a prefix of the
3241/// section; only a query that genuinely touches every graph (e.g. a full
3242/// `COUNT` over `GRAPH ?g`) walks it all — which is exactly what the eager
3243/// open used to fetch unconditionally.
3244///
3245/// **Failure contract** (same as lazy tiles/chunks): a failed read or a
3246/// malformed record sets a sticky flag and the accessor returns `None`;
3247/// nothing failed is ever memoised, so a later evaluation retries.
3248struct LazyNamedGraphs {
3249    reader: std::sync::Arc<dyn RangeReader + Send + Sync>,
3250    /// Absolute byte range of the NAMED_GRAPHS section.
3251    section: ByteRange,
3252    codec: u8,
3253    has_synopsis: bool,
3254    read_concurrency: usize,
3255    /// The file's permutation set — every graph container in a file carries
3256    /// the same one, so it comes from the header rather than per graph.
3257    perms: PermSet,
3258    /// `(count, slab table)` — set once the leading count varint is read.
3259    /// Slabs allocate on first touch; boxed slices never move, so `&` handed
3260    /// out to entries stay valid for `&self`'s lifetime with no unsafe.
3261    #[allow(clippy::type_complexity)]
3262    dir: std::sync::OnceLock<(usize, Box<[std::sync::OnceLock<Box<[NamedEntry]>>]>)>,
3263    walk: std::sync::Mutex<NamedWalk>,
3264    failed: std::sync::atomic::AtomicBool,
3265    /// Tile-cache byte cap applied to each LARGE (lazily-opened, loader-bearing)
3266    /// named graph as it is opened (bounded-export phase 2). `u64::MAX` =
3267    /// unlimited. Small resident graphs (no loader, ≤ 1 MiB) are never capped —
3268    /// an evicted tile could not be reloaded — but they are bounded by size and
3269    /// released per slot, so they need no cap.
3270    tile_cap: std::sync::atomic::AtomicU64,
3271}
3272
3273impl LazyNamedGraphs {
3274    fn new(
3275        reader: std::sync::Arc<dyn RangeReader + Send + Sync>,
3276        section: ByteRange,
3277        codec: u8,
3278        has_synopsis: bool,
3279        read_concurrency: usize,
3280        perms: PermSet,
3281    ) -> Self {
3282        LazyNamedGraphs {
3283            reader,
3284            section,
3285            codec,
3286            has_synopsis,
3287            read_concurrency,
3288            perms,
3289            dir: std::sync::OnceLock::new(),
3290            walk: std::sync::Mutex::new(NamedWalk::default()),
3291            failed: std::sync::atomic::AtomicBool::new(false),
3292            tile_cap: std::sync::atomic::AtomicU64::new(u64::MAX),
3293        }
3294    }
3295
3296    /// Set the tile-cache cap for large named graphs: records it for graphs
3297    /// opened later, and re-caps every large graph already opened. Small
3298    /// resident (loaderless) graphs are left unlimited.
3299    fn set_tile_cap(&self, cap: u64) {
3300        self.tile_cap
3301            .store(cap, std::sync::atomic::Ordering::Relaxed);
3302        self.for_each_opened(|g| {
3303            if g.is_lazy() {
3304                g.set_cache_cap(cap);
3305            }
3306        });
3307    }
3308
3309    /// Record a fetch/parse failure (checked by `index_incomplete`).
3310    fn fail(&self) {
3311        self.failed
3312            .store(true, std::sync::atomic::Ordering::Relaxed);
3313    }
3314
3315    /// The directory scaffold, reading the leading count varint on first call.
3316    #[allow(clippy::type_complexity)]
3317    fn directory(&self) -> Option<&(usize, Box<[std::sync::OnceLock<Box<[NamedEntry]>>]>)> {
3318        if let Some(d) = self.dir.get() {
3319            return Some(d);
3320        }
3321        let end = self.section.offset.checked_add(self.section.len)?;
3322        let (n, used) = match read_uvarint_at(&self.reader, self.section.offset, end) {
3323            Ok(v) => v,
3324            Err(_) => {
3325                self.fail();
3326                return None;
3327            }
3328        };
3329        // Each record needs at least 2 bytes of framing (two length varints),
3330        // so a count beyond len/2 is malformed — refuse before sizing by it.
3331        if n > self.section.len / 2 {
3332            self.fail();
3333            return None;
3334        }
3335        let n = n as usize;
3336        let slabs = n.div_ceil(NAMED_SLAB);
3337        let table: Box<[std::sync::OnceLock<Box<[NamedEntry]>>]> =
3338            (0..slabs).map(|_| std::sync::OnceLock::new()).collect();
3339        let _ = self.dir.set((n, table));
3340        {
3341            // Idempotent under an init race: both racers computed the same
3342            // `used` from the same bytes.
3343            let mut w = self.walk.lock().unwrap();
3344            if w.pos == 0 {
3345                w.pos = self.section.offset + used;
3346            }
3347        }
3348        self.dir.get()
3349    }
3350
3351    fn count(&self) -> usize {
3352        self.directory().map(|(n, _)| *n).unwrap_or(0)
3353    }
3354
3355    /// The `i`-th entry's cell (allocating its slab on first touch).
3356    fn entry(&self, i: usize) -> Option<&NamedEntry> {
3357        let (n, table) = self.directory()?;
3358        if i >= *n {
3359            return None;
3360        }
3361        let slab = table[i / NAMED_SLAB].get_or_init(|| {
3362            let len = NAMED_SLAB.min(n - (i / NAMED_SLAB) * NAMED_SLAB);
3363            (0..len).map(|_| NamedEntry::default()).collect()
3364        });
3365        slab.get(i % NAMED_SLAB)
3366    }
3367
3368    /// The next walk read's length, advancing the geometric ramp: at least
3369    /// `need` (a long IRI's header), at most the section remainder, and
3370    /// otherwise the current ramp value — which doubles per refill up to
3371    /// [`NAMED_WALK_CHUNK_MAX`]. The doubling keeps a stopping walk's wasted
3372    /// read-ahead bounded by the bytes it already used, while a walk that
3373    /// keeps going converges to bulk reads: the runtime safety net for
3374    /// exhaustive-looking queries the demand analysis could not prove.
3375    fn next_read_len(w: &mut NamedWalk, pos: u64, end: u64, need: u64) -> u64 {
3376        let cur = w.chunk.max(NAMED_WALK_CHUNK);
3377        w.chunk = (cur * 2).min(NAMED_WALK_CHUNK_MAX);
3378        cur.max(need).min(end - pos)
3379    }
3380
3381    /// Walk entry headers forward until `entries[upto].meta` is set. Reads the
3382    /// section in chunks and hops over container payloads, so the walk fetches
3383    /// O(headers) — not O(section) — where containers are large; small
3384    /// containers ride along in the same chunk and cost nothing extra.
3385    ///
3386    /// `exhaustive` is the demand hint from the query engine: the caller has
3387    /// PROVEN it will visit and open every named graph (an unrestricted
3388    /// `GRAPH ?g` under a consumer that drains the pipeline), so the walk
3389    /// starts at full-size reads instead of ramping up to them — the whole
3390    /// section arrives in `section/8 MiB` requests instead of hundreds. The
3391    /// hint is per-call, never memoised: a later targeted query on the same
3392    /// handle walks small again. It also defers to the layout: once an
3393    /// oversized (tile-lazy) container is seen, ride-along bytes stop being
3394    /// useful and the hint no longer seeds bulk reads.
3395    fn ensure_meta(&self, upto: usize, exhaustive: bool) -> Option<()> {
3396        let target = self.entry(upto)?;
3397        if target.meta.get().is_some() {
3398            return Some(());
3399        }
3400        let end = self.section.offset.checked_add(self.section.len)?;
3401        let mut w = self.walk.lock().unwrap();
3402        if exhaustive && !w.big_seen {
3403            w.chunk = NAMED_WALK_CHUNK_MAX;
3404        }
3405        while w.next <= upto {
3406            let pos = w.pos;
3407            if pos >= end {
3408                // The count varint promised more entries than the bytes hold.
3409                self.fail();
3410                return None;
3411            }
3412            // Make sure the whole header (two varints + the IRI, ≤ 20+iri_len
3413            // bytes) is buffered; refetch a larger window when the IRI is long.
3414            let have =
3415                |b: &[u8], off: u64, need: u64| pos >= off && pos + need <= off + b.len() as u64;
3416            if !have(&w.buf, w.buf_off, 20.min(end - pos)) {
3417                let len = Self::next_read_len(&mut w, pos, end, 20.min(end - pos));
3418                w.buf = match self.reader.read_at(pos, len) {
3419                    Ok(b) => b,
3420                    Err(_) => {
3421                        self.fail();
3422                        return None;
3423                    }
3424                };
3425                w.buf_off = pos;
3426            }
3427            let rel = (pos - w.buf_off) as usize;
3428            let Some((ilen, u1)) = read_uvarint(&w.buf[rel..]) else {
3429                self.fail();
3430                return None;
3431            };
3432            let header_need = u1 as u64 + ilen + 10; // iri_len + iri + container_len varint
3433            if pos + u1 as u64 + ilen > end {
3434                self.fail(); // IRI overruns the section: malformed
3435                return None;
3436            }
3437            if !have(&w.buf, w.buf_off, header_need.min(end - pos)) {
3438                let len = Self::next_read_len(&mut w, pos, end, header_need);
3439                w.buf = match self.reader.read_at(pos, len) {
3440                    Ok(b) => b,
3441                    Err(_) => {
3442                        self.fail();
3443                        return None;
3444                    }
3445                };
3446                w.buf_off = pos;
3447            }
3448            let rel = (pos - w.buf_off) as usize;
3449            let istart = rel + u1;
3450            let iend = istart + ilen as usize;
3451            let iri = String::from_utf8_lossy(&w.buf[istart..iend]).into_owned();
3452            let Some((clen, u2)) = read_uvarint(&w.buf[iend..]) else {
3453                self.fail();
3454                return None;
3455            };
3456            let cstart = pos + u1 as u64 + ilen + u2 as u64;
3457            let cend = match cstart.checked_add(clen) {
3458                Some(e) if e <= end => e,
3459                _ => {
3460                    self.fail(); // container overruns the section: malformed
3461                    return None;
3462                }
3463            };
3464            if clen > NAMED_GRAPH_RESIDENT_MAX {
3465                // This graph opens tile-lazily: the walk exists to HOP its
3466                // payload, so big read-ahead here is pure over-fetch. Drop the
3467                // ramp back to the floor and stop the exhaustive hint from
3468                // re-seeding it (headers stay cheap on payload-heavy files).
3469                w.big_seen = true;
3470                w.chunk = NAMED_WALK_CHUNK;
3471            }
3472            let range = ByteRange {
3473                offset: cstart,
3474                len: clen,
3475            };
3476            if let Some(e) = self.entry(w.next) {
3477                let _ = e.meta.set((iri, range));
3478            }
3479            w.pos = cend;
3480            w.next += 1;
3481        }
3482        Some(())
3483    }
3484
3485    /// The `i`-th graph's IRI — a header walk, never an index decode.
3486    /// `exhaustive`: see [`ensure_meta`](Self::ensure_meta).
3487    fn name_at(&self, i: usize, exhaustive: bool) -> Option<&str> {
3488        self.ensure_meta(i, exhaustive)?;
3489        self.entry(i)?.meta.get().map(|(iri, _)| iri.as_str())
3490    }
3491
3492    /// The `i`-th graph as `(iri, index)`, opening the index on first access.
3493    /// `exhaustive`: see [`ensure_meta`](Self::ensure_meta).
3494    fn graph_at(&self, i: usize, exhaustive: bool) -> Option<(&str, &GraphIndex)> {
3495        self.ensure_meta(i, exhaustive)?;
3496        let e = self.entry(i)?;
3497        let (iri, range) = e.meta.get()?;
3498        if e.index.get().is_none() {
3499            let opened = self.open_graph(*range)?;
3500            let _ = e.index.set(Box::new(opened));
3501        }
3502        Some((iri.as_str(), e.index.get()?.as_ref()))
3503    }
3504
3505    /// A small container's bytes: out of the walk buffer when the last chunk
3506    /// already carried them (bulk chunks always do — that is their point),
3507    /// else one targeted range read. Never called for tile-lazy containers.
3508    fn container_bytes(&self, range: ByteRange) -> Option<Vec<u8>> {
3509        {
3510            let w = self.walk.lock().unwrap();
3511            let buf_end = w.buf_off + w.buf.len() as u64;
3512            if range.offset >= w.buf_off && range.offset + range.len <= buf_end {
3513                let a = (range.offset - w.buf_off) as usize;
3514                return Some(w.buf[a..a + range.len as usize].to_vec());
3515            }
3516        }
3517        match self.reader.read_at(range.offset, range.len) {
3518            Ok(b) => Some(b),
3519            Err(_) => {
3520                self.fail();
3521                None
3522            }
3523        }
3524    }
3525
3526    /// Open one graph's index container: small ones resident (one read),
3527    /// large ones as a remote-lazy tile index — the same machinery as the
3528    /// default graph, so a selective query over a huge named graph fetches
3529    /// only the tile directories plus the tiles it touches.
3530    fn open_graph(&self, range: ByteRange) -> Option<GraphIndex> {
3531        if range.len <= NAMED_GRAPH_RESIDENT_MAX {
3532            let bytes = self.container_bytes(range)?;
3533            match decode_index_container(&bytes, self.codec, self.perms) {
3534                Ok(g) => Some(g),
3535                Err(_) => {
3536                    self.fail();
3537                    None
3538                }
3539            }
3540        } else {
3541            match open_index_container_lazy(
3542                &self.reader,
3543                range,
3544                self.codec,
3545                self.has_synopsis,
3546                self.read_concurrency,
3547                self.perms,
3548                self.perms.len(),
3549            ) {
3550                Ok((g, _, _)) => {
3551                    // A large graph faults its tiles through a loader, so bound
3552                    // its tile residency to the budget's share (an evicted tile
3553                    // simply re-faults). No-op when unlimited.
3554                    let cap = self.tile_cap.load(std::sync::atomic::Ordering::Relaxed);
3555                    if cap != u64::MAX {
3556                        g.set_cache_cap(cap);
3557                    }
3558                    Some(g)
3559                }
3560                Err(_) => {
3561                    self.fail();
3562                    None
3563                }
3564            }
3565        }
3566    }
3567
3568    /// Drop the decoded index of the graph named `iri`, if it has been opened,
3569    /// so its tiles and per-graph tile cache are freed. Takes `&mut self`:
3570    /// `OnceLock::take` needs unique access, which the export loop has between
3571    /// slots. Leaves the entry's `meta` (iri + range) so the graph re-opens
3572    /// lazily if touched again. A no-op if the graph is absent or never opened.
3573    fn release(&mut self, iri: &str) {
3574        let Some((_, table)) = self.dir.get_mut() else {
3575            return;
3576        };
3577        for slab in table.iter_mut() {
3578            let Some(entries) = slab.get_mut() else {
3579                continue;
3580            };
3581            for e in entries.iter_mut() {
3582                let matches = e
3583                    .meta
3584                    .get()
3585                    .map(|(name, _)| name.as_str() == iri)
3586                    .unwrap_or(false);
3587                if matches {
3588                    e.index.take();
3589                    return;
3590                }
3591            }
3592        }
3593    }
3594
3595    /// Visit every already-opened graph index (never triggers a fetch).
3596    fn for_each_opened(&self, mut f: impl FnMut(&GraphIndex)) {
3597        if let Some((_, table)) = self.dir.get() {
3598            for slab in table.iter().filter_map(|s| s.get()) {
3599                for e in slab.iter() {
3600                    if let Some(g) = e.index.get() {
3601                        f(g);
3602                    }
3603                }
3604            }
3605        }
3606    }
3607}
3608
3609/// The NAMED_GRAPHS section, held resident (in-memory and eager ranged opens —
3610/// unchanged) or walked lazily (the lazy ranged open, where fetching and
3611/// decoding every graph's index up front defeated remote laziness: 67 MB
3612/// fetched and ~32k indexes built before the first query on a many-graph file).
3613enum NamedGraphsSlot {
3614    Resident(Vec<(String, GraphIndex)>),
3615    Lazy(LazyNamedGraphs),
3616    /// The quad index (kind 10), on every open path: graphs are views over
3617    /// shared tiles, found through the dictionary and the sorted directory.
3618    Quad(crate::quad::QuadNamed),
3619}
3620
3621impl NamedGraphsSlot {
3622    /// Heap bytes the named-graph directory holds outside any cache: every
3623    /// walked entry's IRI and range, the walk buffer, and the directories of
3624    /// the graph indexes currently open (see [`GraphIndex::directory_bytes`]).
3625    fn directory_bytes(&self) -> u64 {
3626        match self {
3627            NamedGraphsSlot::Resident(v) => v
3628                .iter()
3629                .map(|(iri, g)| iri.capacity() as u64 + g.directory_bytes())
3630                .sum(),
3631            NamedGraphsSlot::Lazy(l) => {
3632                let mut bytes = l.walk.lock().map(|w| w.buf.capacity() as u64).unwrap_or(0);
3633                if let Some((_, table)) = l.dir.get() {
3634                    bytes += table.len() as u64
3635                        * std::mem::size_of::<std::sync::OnceLock<Box<[NamedEntry]>>>() as u64;
3636                    for slab in table.iter().filter_map(|s| s.get()) {
3637                        bytes += slab.len() as u64 * std::mem::size_of::<NamedEntry>() as u64;
3638                        for e in slab.iter() {
3639                            if let Some((iri, _)) = e.meta.get() {
3640                                bytes += iri.capacity() as u64;
3641                            }
3642                            if let Some(g) = e.index.get() {
3643                                bytes += g.directory_bytes();
3644                            }
3645                        }
3646                    }
3647                }
3648                bytes
3649            }
3650            NamedGraphsSlot::Quad(q) => q.directory_bytes(),
3651        }
3652    }
3653
3654    fn count(&self) -> usize {
3655        match self {
3656            NamedGraphsSlot::Resident(v) => v.len(),
3657            NamedGraphsSlot::Lazy(l) => l.count(),
3658            NamedGraphsSlot::Quad(q) => q.count(),
3659        }
3660    }
3661
3662    /// `exhaustive` is the walk-demand hint (resident opens ignore it): see
3663    /// [`LazyNamedGraphs::ensure_meta`]. `dict` names the graphs of a quad
3664    /// index (their names are dictionary terms there).
3665    fn name_at<'a>(&'a self, dict: &Dictionary, i: usize, exhaustive: bool) -> Option<&'a str> {
3666        match self {
3667            NamedGraphsSlot::Resident(v) => v.get(i).map(|(iri, _)| iri.as_str()),
3668            NamedGraphsSlot::Lazy(l) => l.name_at(i, exhaustive),
3669            NamedGraphsSlot::Quad(q) => q.name_at(dict, i, exhaustive),
3670        }
3671    }
3672
3673    fn graph_at<'a>(
3674        &'a self,
3675        dict: &Dictionary,
3676        i: usize,
3677        exhaustive: bool,
3678    ) -> Option<(&'a str, &'a GraphIndex)> {
3679        match self {
3680            NamedGraphsSlot::Resident(v) => v.get(i).map(|(iri, g)| (iri.as_str(), g)),
3681            NamedGraphsSlot::Lazy(l) => l.graph_at(i, exhaustive),
3682            NamedGraphsSlot::Quad(q) => q.graph_at(dict, i, exhaustive),
3683        }
3684    }
3685
3686    fn find<'a>(&'a self, dict: &Dictionary, iri: &str) -> Option<&'a GraphIndex> {
3687        match self {
3688            NamedGraphsSlot::Quad(q) => q.find(dict, iri),
3689            NamedGraphsSlot::Resident(v) => v.iter().find(|(name, _)| name == iri).map(|(_, g)| g),
3690            NamedGraphsSlot::Lazy(l) => {
3691                // Walk headers only until the IRI matches; decode just that
3692                // graph's index. An absent IRI costs a full header walk — the
3693                // layout has no by-name directory. A point lookup is targeted
3694                // demand: never hint the walk exhaustive.
3695                for i in 0..l.count() {
3696                    if l.name_at(i, false)? == iri {
3697                        return l.graph_at(i, false).map(|(_, g)| g);
3698                    }
3699                }
3700                None
3701            }
3702        }
3703    }
3704
3705    fn load_incomplete(&self) -> bool {
3706        match self {
3707            NamedGraphsSlot::Resident(v) => v.iter().any(|(_, g)| g.load_incomplete()),
3708            NamedGraphsSlot::Lazy(l) => {
3709                if l.failed.load(std::sync::atomic::Ordering::Relaxed) {
3710                    return true;
3711                }
3712                let mut bad = false;
3713                l.for_each_opened(|g| bad |= g.load_incomplete());
3714                bad
3715            }
3716            NamedGraphsSlot::Quad(q) => q.load_incomplete(),
3717        }
3718    }
3719
3720    fn reset_load_failures(&self) {
3721        match self {
3722            NamedGraphsSlot::Resident(v) => {
3723                for (_, g) in v {
3724                    g.reset_load_failure();
3725                }
3726            }
3727            NamedGraphsSlot::Lazy(l) => {
3728                l.failed.store(false, std::sync::atomic::Ordering::Relaxed);
3729                l.for_each_opened(|g| g.reset_load_failure());
3730            }
3731            NamedGraphsSlot::Quad(q) => q.reset_load_failures(),
3732        }
3733    }
3734
3735    /// The named-graph slot of a file opened with all its bytes in hand
3736    /// (`Rete::open`, `open_ranged`): the per-graph section decoded resident,
3737    /// or the quad index read through an owned copy of its section.
3738    fn resident(
3739        header: &Header,
3740        quad_section: impl FnOnce(u64, u64) -> Result<Vec<u8>, FileError>,
3741        per_graph: impl FnOnce() -> Result<Vec<(String, GraphIndex)>, FileError>,
3742    ) -> Result<Self, FileError> {
3743        check_named_layout(header)?;
3744        if let Some(q) = header.quad_index_section() {
3745            let bytes = quad_section(q.offset, q.length)?;
3746            return Ok(NamedGraphsSlot::Quad(crate::quad::QuadNamed::resident(
3747                bytes,
3748                header.block_codec,
3749                header.perms,
3750            )));
3751        }
3752        Ok(NamedGraphsSlot::Resident(if header.named_graphs_len > 0 {
3753            per_graph()?
3754        } else {
3755            Vec::new()
3756        }))
3757    }
3758}
3759
3760/// A read-only, in-memory view over a `.rete` file image.
3761pub struct Rete {
3762    header: Header,
3763    dict: Dictionary,
3764    index: GraphIndex,
3765    index_section_ranges: [ByteRange; NUM_PERMS],
3766    /// Per-permutation tile directories as absolute file ranges
3767    /// (`(min_a, max_a, compressed-tile range)`), for provenance. Empty for
3768    /// pre-tiling (v0.1) files.
3769    tile_ranges: [Vec<(u32, u32, ByteRange)>; NUM_PERMS],
3770    pyramid: PyramidSlot,
3771    text_index: TextIndexSlot,
3772    /// The LABEL_INDEX section (kind 8), if any.
3773    labels: SectionSlot<crate::label_index::LabelIndex>,
3774    /// The SCHEMA section (kind 9), if any.
3775    schema: SectionSlot<crate::schema_section::SchemaMeta>,
3776    named_graphs: NamedGraphsSlot,
3777    /// Raw bytes of the metadata section (empty if the file has none). The
3778    /// application layer decodes this (the CLI stores a JSON Dataset Card here).
3779    /// Only [`Rete::open`] populates it; [`Rete::open_ranged`] leaves it empty to
3780    /// preserve its minimal-fetch budget.
3781    metadata: Vec<u8>,
3782    /// Executes remote `SERVICE` blocks (SPARQL 1.1 federated query) — attached
3783    /// by the host via [`Rete::set_service_client`]; `None` means a non-SILENT
3784    /// `SERVICE` fails the query. Like the range readers, the engine never does
3785    /// I/O itself.
3786    service_client: Option<Box<dyn crate::service::ServiceClient>>,
3787    /// First failed non-SILENT `SERVICE` call of the current query. The row
3788    /// pipeline is infallible (the same contract as lazy tile fetches), so the
3789    /// failure is recorded here and taken by the top-level eval entry points.
3790    service_error: std::sync::Mutex<Option<String>>,
3791    /// Bytes a query's `COUNT(DISTINCT …)` set may hold before it spills to
3792    /// disk — see [`Rete::spill_threshold`].
3793    spill_threshold: std::sync::atomic::AtomicU64,
3794}
3795
3796impl Rete {
3797    /// Parse a full file image (v0 loads everything; a range-reading client
3798    /// will fetch only the sections it needs — same container format).
3799    pub fn open(bytes: &[u8]) -> Result<Self, FileError> {
3800        let header = Header::from_bytes(bytes)?;
3801
3802        // Header offsets/lengths are untrusted (a `.rete` may be fetched truncated
3803        // or corrupt from an arbitrary URL). Slice through a checked helper so a
3804        // bad region yields an error instead of panicking on an OOB index.
3805        let region = |off: u64, len: u64| -> Result<&[u8], FileError> {
3806            let start = off as usize;
3807            let end = start
3808                .checked_add(len as usize)
3809                .filter(|&e| e <= bytes.len())
3810                .ok_or(FileError::Container("section range out of bounds"))?;
3811            Ok(&bytes[start..end])
3812        };
3813
3814        let dict = decode_dictionary_container(
3815            region(header.dictionary_offset, header.dictionary_len)?,
3816            header.dict_codec,
3817        )?;
3818
3819        check_named_layout(&header)?;
3820        let index_bytes = region(header.root_dir_offset, header.root_dir_len)?;
3821        let (index, _) = decode_root_index_container(index_bytes, &header)?;
3822        let index_section_ranges = decode_index_section_ranges(
3823            index_bytes,
3824            header.root_dir_offset,
3825            header.perms,
3826            header.index_container_sections(),
3827        )?;
3828
3829        let pyramid = PyramidSlot::Resident(if header.pyramid_meta_len > 0 {
3830            Some(
3831                PyramidMeta::decode(region(header.pyramid_meta_offset, header.pyramid_meta_len)?)
3832                    .map_err(|_| FileError::Container("malformed pyramid meta"))?,
3833            )
3834        } else {
3835            None
3836        });
3837
3838        // The TEXT_INDEX section (opt-in `--text-index`); decode the whole thing
3839        // resident on a full-image open.
3840        let (ti_off, ti_len) = header.text_index_payload();
3841        let text_index = resident_text_index_slot(
3842            if ti_len > 0 {
3843                Some(region(ti_off, ti_len)?)
3844            } else {
3845                None
3846            },
3847            header.block_codec,
3848        )?;
3849        let labels = resident_label_slot(&header, &region);
3850        let schema = resident_schema_slot(&header, &region);
3851
3852        let named_graphs = NamedGraphsSlot::resident(
3853            &header,
3854            |off, len| Ok(region(off, len)?.to_vec()),
3855            || {
3856                decode_named_graphs(
3857                    region(header.named_graphs_offset, header.named_graphs_len)?,
3858                    header.block_codec,
3859                    header.perms,
3860                )
3861            },
3862        )?;
3863
3864        let metadata = if header.metadata_len > 0 {
3865            region(header.metadata_offset, header.metadata_len)?.to_vec()
3866        } else {
3867            Vec::new()
3868        };
3869
3870        let tile_ranges =
3871            tile_file_ranges(index_bytes, header.root_dir_offset, &index_section_ranges);
3872        Ok(Self {
3873            header,
3874            dict,
3875            index,
3876            index_section_ranges,
3877            tile_ranges,
3878            pyramid,
3879            text_index,
3880            labels,
3881            schema,
3882            named_graphs,
3883            metadata,
3884            service_client: None,
3885            service_error: std::sync::Mutex::new(None),
3886            spill_threshold: std::sync::atomic::AtomicU64::new(u64::MAX),
3887        })
3888    }
3889
3890    /// Attach the client that executes `SERVICE <endpoint> { … }` blocks
3891    /// (SPARQL 1.1 federated query) against remote SPARQL endpoints. Without
3892    /// one, a non-SILENT `SERVICE` fails the query with a clear error and a
3893    /// `SERVICE SILENT` degrades to one empty solution, per the spec.
3894    pub fn set_service_client(&mut self, client: Box<dyn crate::service::ServiceClient>) {
3895        self.service_client = Some(client);
3896    }
3897
3898    pub(crate) fn service_client(&self) -> Option<&dyn crate::service::ServiceClient> {
3899        self.service_client.as_deref()
3900    }
3901
3902    /// Record a failed non-SILENT `SERVICE` call (first error wins).
3903    pub(crate) fn record_service_error(&self, msg: &str) {
3904        let mut e = self.service_error.lock().unwrap();
3905        if e.is_none() {
3906            *e = Some(msg.to_string());
3907        }
3908    }
3909
3910    /// Take (and clear) the pending `SERVICE` failure — called by every
3911    /// top-level eval entry so it can never leak into a later query.
3912    pub(crate) fn take_service_error(&self) -> Option<String> {
3913        self.service_error.lock().unwrap().take()
3914    }
3915
3916    pub fn header(&self) -> &Header {
3917        &self.header
3918    }
3919
3920    /// The file's byte layout, for visualization: header, metadata,
3921    /// dictionary, each index permutation's tile directory and individual
3922    /// tiles, pyramid summary, and named graphs — sorted by offset. Bytes not
3923    /// covered by any segment are container framing (section directories and
3924    /// length fields).
3925    pub fn file_layout(&self) -> Vec<LayoutSegment> {
3926        let h = &self.header;
3927        let seg = |kind: &'static str, label: String, offset: u64, len: u64| LayoutSegment {
3928            kind,
3929            label,
3930            offset,
3931            len,
3932        };
3933        // The size in the label is DERIVED from the constant that defines the
3934        // span, never typed out: this line read "fixed 128 bytes" — the
3935        // pre-v0.3 header size — while emitting a 1024-byte span, and it is
3936        // what the file explorer shows a reader inspecting a real file.
3937        // `layout_header_label_matches_its_span` pins the two together.
3938        let mut out = vec![seg(
3939            "header",
3940            format!("header (fixed {} bytes)", crate::header::HEADER_LEN),
3941            0,
3942            crate::header::HEADER_LEN as u64,
3943        )];
3944        if h.metadata_len > 0 {
3945            out.push(seg(
3946                "metadata",
3947                "metadata (dataset card)".into(),
3948                h.metadata_offset,
3949                h.metadata_len,
3950            ));
3951        }
3952        out.push(seg(
3953            "dictionary",
3954            "dictionary (4 front-coded term sections)".into(),
3955            h.dictionary_offset,
3956            h.dictionary_len,
3957        ));
3958        for (si, perm) in crate::index::ALL_PERMS.into_iter().enumerate() {
3959            let sec = self.index_section_ranges[si];
3960            if sec.len == 0 {
3961                continue;
3962            }
3963            let first_tile = self.tile_ranges[si]
3964                .first()
3965                .map(|&(_, _, r)| r.offset)
3966                .unwrap_or(sec.offset + sec.len);
3967            if first_tile > sec.offset {
3968                out.push(seg(
3969                    "directory",
3970                    format!("{} tile directory", perm.name()),
3971                    sec.offset,
3972                    first_tile - sec.offset,
3973                ));
3974            }
3975            for (ti, &(min_a, max_a, r)) in self.tile_ranges[si].iter().enumerate() {
3976                out.push(seg(
3977                    "tile",
3978                    format!("{} tile {ti} (leading ids {min_a}..{max_a})", perm.name()),
3979                    r.offset,
3980                    r.len,
3981                ));
3982            }
3983        }
3984        if h.pyramid_meta_len > 0 {
3985            out.push(seg(
3986                "pyramid",
3987                "pyramid summary (communities + superedges)".into(),
3988                h.pyramid_meta_offset,
3989                h.pyramid_meta_len,
3990            ));
3991        }
3992        if h.named_graphs_len > 0 {
3993            out.push(seg(
3994                "named-graphs",
3995                format!("named graphs ({})", self.named_graphs.count()),
3996                h.named_graphs_offset,
3997                h.named_graphs_len,
3998            ));
3999        }
4000        if let Some(s) = h.label_index_section() {
4001            let label = match self.labels.get_if_loaded() {
4002                Some(li) => format!("label index ({} entries)", li.entry_count()),
4003                None => "label index".to_string(),
4004            };
4005            out.push(seg("label-index", label, s.offset, s.length));
4006        }
4007        if let Some(s) = h.schema_section() {
4008            out.push(seg(
4009                "schema",
4010                "schema (classes, query_stats, shapes)".into(),
4011                s.offset,
4012                s.length,
4013            ));
4014        }
4015        if let Some(q) = h.quad_index_section() {
4016            out.push(seg(
4017                "quad-index",
4018                format!("quad index ({} named graphs)", self.named_graphs.count()),
4019                q.offset,
4020                q.length,
4021            ));
4022        }
4023        out.sort_by_key(|s| s.offset);
4024        out
4025    }
4026
4027    /// Raw bytes of the file's metadata section, or `None` if it has none. The
4028    /// CLI stores a JSON Dataset Card here; `rete-core` treats it as opaque.
4029    /// Populated by [`Rete::open`] only — an [`Rete::open_ranged`] view returns
4030    /// `None` here (the card is not fetched on the minimal query path).
4031    pub fn metadata(&self) -> Option<&[u8]> {
4032        if self.metadata.is_empty() {
4033            None
4034        } else {
4035            Some(&self.metadata)
4036        }
4037    }
4038
4039    pub fn dictionary(&self) -> &Dictionary {
4040        &self.dict
4041    }
4042
4043    /// The pyramid metadata (summary graph + tiles), if the file has a pyramid.
4044    pub fn pyramid(&self) -> Option<&PyramidMeta> {
4045        match &self.pyramid {
4046            PyramidSlot::Resident(p) => p.as_ref(),
4047            // Faults the (possibly large) pyramid section on first access only.
4048            PyramidSlot::Lazy { loader, cell } => cell.get_or_init(loader).as_ref(),
4049        }
4050    }
4051
4052    /// The pyramid metadata **only if already resident or previously faulted** —
4053    /// never triggers a lazy range read. The query planner uses this for
4054    /// cardinality estimation so it is free for an in-memory file and never adds
4055    /// a fetch on the lazy remote path (which defers the pyramid by design).
4056    pub fn pyramid_if_loaded(&self) -> Option<&PyramidMeta> {
4057        match &self.pyramid {
4058            PyramidSlot::Resident(p) => p.as_ref(),
4059            PyramidSlot::Lazy { cell, .. } => cell.get().and_then(|o| o.as_ref()),
4060        }
4061    }
4062
4063    /// The SCHEMA section (kind 9): class hierarchy, level rollups and links,
4064    /// coherence axioms, `query_stats`, characteristic sets. Faults it in (one
4065    /// small range read) on the lazy path. `None` when the file has none — a
4066    /// file built before it existed keeps those fields in its pyramid-meta
4067    /// ([`schema_or_pyramid`](Self::schema_or_pyramid) reads either).
4068    pub fn schema(&self) -> Option<&crate::schema_section::SchemaMeta> {
4069        self.schema.get()
4070    }
4071
4072    /// The schema fields from the SCHEMA section when the file has one, else
4073    /// from the pyramid-meta (faulting either on the lazy path). `None` when
4074    /// the file carries neither.
4075    pub fn schema_or_pyramid(
4076        &self,
4077    ) -> Option<std::borrow::Cow<'_, crate::schema_section::SchemaMeta>> {
4078        if let Some(s) = self.schema() {
4079            return Some(std::borrow::Cow::Borrowed(s));
4080        }
4081        self.pyramid()
4082            .map(|p| std::borrow::Cow::Owned(crate::schema_section::SchemaMeta::from_pyramid(p)))
4083    }
4084
4085    /// Per-predicate planner statistics (`query_stats`): from the SCHEMA
4086    /// section (faulting it on the lazy path — it is small), else from the
4087    /// pyramid-meta when that is resident. Empty when the file has neither.
4088    /// See [`crate::meta::PredStat`].
4089    pub fn predicate_stats(&self) -> &[crate::meta::PredStat] {
4090        if let Some(s) = self.schema().filter(|s| !s.predicate_stats.is_empty()) {
4091            return &s.predicate_stats;
4092        }
4093        self.pyramid_if_loaded()
4094            .map(|p| p.predicate_stats.as_slice())
4095            .unwrap_or(&[])
4096    }
4097
4098    /// The statistics the planner may use **without a read it was not going to
4099    /// make**: the SCHEMA section if it is resident or already faulted — or,
4100    /// with `fault`, after faulting it (one small range, once per session) —
4101    /// else the resident pyramid-meta's block.
4102    pub(crate) fn planner_stats(&self, fault: bool) -> &[crate::meta::PredStat] {
4103        let schema = if fault {
4104            self.schema.get()
4105        } else {
4106            self.schema.get_if_loaded()
4107        };
4108        if let Some(s) = schema.filter(|s| !s.predicate_stats.is_empty()) {
4109            return &s.predicate_stats;
4110        }
4111        self.pyramid_if_loaded()
4112            .map(|p| p.predicate_stats.as_slice())
4113            .unwrap_or(&[])
4114    }
4115
4116    /// The entity shapes (characteristic sets): from the SCHEMA section, else
4117    /// from a resident pyramid-meta. See [`crate::meta::CharSet`].
4118    pub fn char_sets(&self) -> &[crate::meta::CharSet] {
4119        if let Some(s) = self.schema().filter(|s| !s.char_sets.is_empty()) {
4120            return &s.char_sets;
4121        }
4122        self.pyramid_if_loaded()
4123            .map(|p| p.char_sets.as_slice())
4124            .unwrap_or(&[])
4125    }
4126
4127    /// The **legacy** label block of a pyramid-meta (≤ 8,192 entries) — empty
4128    /// when the file has none or the pyramid isn't resident. Files built with
4129    /// `--label-index` carry a LABEL_INDEX section instead; see
4130    /// [`label_index_section`](Self::label_index_section). See
4131    /// [`crate::meta::LabelEntry`].
4132    pub fn label_index(&self) -> &[crate::meta::LabelEntry] {
4133        self.pyramid_if_loaded()
4134            .map(|p| p.label_index.as_slice())
4135            .unwrap_or(&[])
4136    }
4137
4138    /// Whether the file carries a LABEL_INDEX section (kind 8). Header only.
4139    pub fn has_label_index(&self) -> bool {
4140        self.header.label_index_section().is_some()
4141    }
4142
4143    /// The LABEL_INDEX section, faulting its head in on the lazy path.
4144    pub fn label_index_section(&self) -> Option<&crate::label_index::LabelIndex> {
4145        self.labels.get()
4146    }
4147
4148    /// Prefix-search the labels: the subjects whose label starts with `prefix`
4149    /// (case-insensitive), as `(label, subject term)`, capped at `limit`.
4150    /// Reads the LABEL_INDEX section when the file has one (every labelled
4151    /// subject, no dictionary read); otherwise the legacy pyramid-meta block,
4152    /// which **faults the pyramid** on the lazy path. Empty when the file
4153    /// carries neither.
4154    pub fn prefix_search(&self, prefix: &str, limit: usize) -> Vec<(String, String)> {
4155        if let Some(li) = self.labels.get() {
4156            return li.prefix_search(prefix, limit);
4157        }
4158        let Some(pyr) = self.pyramid() else {
4159            return Vec::new();
4160        };
4161        resolve_label_hits(&self.dict, pyr.prefix_search(prefix, limit))
4162    }
4163
4164    /// The full-text index (TEXT_INDEX section), faulting it in on first access
4165    /// on the lazy remote path. `None` when the file carries no text index.
4166    pub(crate) fn text_index(&self) -> Option<&crate::text_index::TextIndex> {
4167        self.text_index.index()
4168    }
4169
4170    /// Whether this file carries a full-text (TEXT_INDEX) section, i.e. it was
4171    /// built with `--text-index`. Cheap — reads the header, never faults.
4172    pub fn has_text_index(&self) -> bool {
4173        self.header.text_index_len > 0
4174    }
4175
4176    /// What the full-text index covers (graphs, predicates) — `None` when the
4177    /// file has none. Faults the token table on the lazy remote path, as a
4178    /// search does; the scope block lives in it.
4179    pub fn text_index_scope(&self) -> Option<crate::text_index::TextIndexScope> {
4180        self.text_index().map(|ti| ti.scope().clone())
4181    }
4182
4183    /// Byte length of the TEXT_INDEX section's leading **token table** — the
4184    /// length varint plus the compressed table, which is exactly what a first
4185    /// [`text_search`](Self::text_search) faults. `None` when the file carries
4186    /// no text index (or its head could not be read).
4187    ///
4188    /// This is the number to quote as the cost of a first search, NOT
4189    /// `header().text_index_len`: that counts the postings blob too, which is
4190    /// the bulk of the section and is only ever fetched one posting list at a
4191    /// time. On the published `epfl-infoscience.rete` the section is 195 MB and
4192    /// its token table 29 MB — a 6.5× difference.
4193    ///
4194    /// Costs nothing on a resident open (measured while the bytes were in hand);
4195    /// a lazy/ranged open pays one ≤10-byte range read, memoized, and still
4196    /// never faults the table itself.
4197    pub fn text_index_token_table_len(&self) -> Option<u64> {
4198        self.text_index.token_table_len()
4199    }
4200
4201    /// Full-text search over the literals: subject IRIs that carry **every** word
4202    /// in `words` (whole-word, case-insensitive — AND semantics), optionally also
4203    /// requiring a word that **starts with** `prefix` (token-prefix). Results are
4204    /// ordered by subject id and capped at `limit` (0 = uncapped). Empty when the
4205    /// file has no text index or nothing matches.
4206    ///
4207    /// Like [`prefix_search`](Self::prefix_search), this **faults** the index on
4208    /// the lazy remote path — a search is an explicit read, and only the queried
4209    /// posting lists are fetched, not the whole index.
4210    pub fn text_search(&self, words: &[&str], prefix: Option<&str>, limit: usize) -> Vec<String> {
4211        text_search_in(self.text_index(), &self.dict, words, prefix, limit)
4212    }
4213
4214    /// The default-graph permutation index.
4215    pub fn default_index(&self) -> &GraphIndex {
4216        &self.index
4217    }
4218
4219    /// Resolve every triple of a graph (`None` = default graph) back to terms.
4220    pub fn dump(&self, graph: Option<&str>) -> Vec<TermTriple> {
4221        let index = match graph {
4222            None => &self.index,
4223            Some(g) => match self.graph_index(g) {
4224                Some(i) => i,
4225                None => return Vec::new(),
4226            },
4227        };
4228        index
4229            .match_pattern((None, None, None))
4230            .into_iter()
4231            .filter_map(|(s, p, o)| {
4232                Some((
4233                    self.dict.subject_term(s)?,
4234                    self.dict.predicate_term(p)?,
4235                    self.dict.object_term(o)?,
4236                ))
4237            })
4238            .collect()
4239    }
4240
4241    /// Stream every triple of a graph (`None` = default) to `f`, resolving terms
4242    /// a batch at a time — no full `Vec` materialization, so it is safe on graphs far
4243    /// larger than RAM. `rete export` uses this to serialize 100M+ triple files
4244    /// that `dump()` (which collects every term into a `Vec<String>`) would OOM on.
4245    ///
4246    /// On a lazy/ranged open the dictionary is **not** prefetched whole: each
4247    /// batch faults only the chunks its own terms live in. Dumping ONE named
4248    /// graph therefore costs that graph's terms, not the file's. Dumping
4249    /// *everything* still reads every dictionary byte and leaves every chunk
4250    /// resident — that is inherent, and it, not the prefetch, is what sets the
4251    /// peak of a full export. See the comment in the body for both measurements.
4252    ///
4253    /// To dump a *slice* — one predicate, one subject, one object — call
4254    /// [`dump_filtered_each`](Self::dump_filtered_each), which is this method
4255    /// with a triple pattern and prunes tiles instead of filtering rows.
4256    pub fn dump_each<F: FnMut(&str, &str, &str)>(&self, graph: Option<&str>, f: F) {
4257        self.dump_filtered_each(graph, None, None, None, f)
4258    }
4259
4260    /// [`dump_each`](Self::dump_each) restricted to a triple **pattern** — the
4261    /// filtered dump. Any of `s` / `p` / `o` may be bound (canonical N-Triples
4262    /// term tokens); all three unbound is exactly `dump_each`.
4263    ///
4264    /// # Why this is cheap, and how much
4265    ///
4266    /// It is the same access path a routed *query* takes, not a full scan with a
4267    /// predicate test bolted on: [`GraphIndex::scan_iter`] picks the permutation
4268    /// with the longest bound prefix, binary-searches that section's tile
4269    /// directory down to the span a bound leading id can live in, and then drops
4270    /// every remaining tile whose recorded synopsis proves it cannot hold a
4271    /// bound secondary component — **all from directories, without fetching a
4272    /// tile**. So a filtered dump pays for the slice, which is the whole point
4273    /// of issue #117.
4274    ///
4275    /// Measured on `cordis.rete` (801 MB, a 417 MB dictionary, 26.4M quads,
4276    /// six named graphs), lazily opened; "before" is the only way to get a
4277    /// predicate slice out of a dump previously — dump the graph and throw away
4278    /// what does not match, in the consumer:
4279    ///
4280    /// ```text
4281    ///   graph=results  p=s66#doi     -> 337,811 rows
4282    ///     before  375.8 MB · 1797 req · 2105.8 MB RSS · 12.8 s · first row 409 ms
4283    ///     after    16.0 MB ·  105 req ·  182.6 MB RSS ·  0.41 s · first row  14 ms
4284    ///   graph=results  p=s66#isbn    ->  40,761 rows
4285    ///     before  375.8 MB · 1797 req · 2105.7 MB RSS · 12.8 s
4286    ///     after    15.5 MB ·  104 req ·  155.2 MB RSS ·  0.23 s
4287    /// ```
4288    ///
4289    /// # Where it does NOT help
4290    ///
4291    /// The index is pruned; the **dictionary is not**. Term resolution still
4292    /// faults whichever chunks the surviving rows' terms live in, and on a file
4293    /// whose payload *is* the literals those chunks are most of the file. Same
4294    /// file, a predicate whose objects are the long abstracts:
4295    ///
4296    /// ```text
4297    ///   graph=projects p=s66#abstract ->  80,206 rows
4298    ///     before  260.7 MB · 1142 req · 1496.9 MB RSS · 5.97 s
4299    ///     after   213.4 MB · 1606 req · 1144.4 MB RSS · 2.91 s   (1.2x, not 23x)
4300    /// ```
4301    ///
4302    /// An *unfiltered* dump is unchanged by construction — it is the floor, and
4303    /// its peak is the resident dictionary, not this. Two ceilings sit above
4304    /// both: a faulted dictionary chunk is a `OnceCell` nothing ever evicts, and
4305    /// on a literal-heavy file the chunk directory alone can be a third of the
4306    /// file before any dump work at all (#198).
4307    ///
4308    /// # Permutations
4309    ///
4310    /// Every one of the eight bound/unbound shapes routes inside
4311    /// [`PermSet::CORE`] — `{SPO, POS, OSP}`, the minimum a legal file carries
4312    /// (see `perm_routing_never_leaves_core`). A file built with
4313    /// `--permutations 3` therefore prunes *identically* to a six-permutation
4314    /// one: same tiles, same rows, no fallback path to get wrong.
4315    ///
4316    /// A bound term the dictionary does not know, or an absent graph IRI, yields
4317    /// nothing without touching the index.
4318    ///
4319    /// # Order
4320    ///
4321    /// Rows arrive in the **routed permutation's** order, which is the price of
4322    /// routing at all. Unfiltered (and subject-bound) that is SPO — canonical
4323    /// `(s, p, o)`, unchanged from [`dump_each`](Self::dump_each). A bound
4324    /// predicate routes to POS and streams `(p, o, s)`; a bound object routes to
4325    /// OSP. The *set* is identical to the unfiltered dump filtered; the order is
4326    /// not, so a consumer that needs canonical order must sort. N-Quads, the
4327    /// format `rete export` writes, is order-independent.
4328    ///
4329    /// [`PermSet::CORE`]: crate::index::PermSet::CORE
4330    pub fn dump_filtered_each<F: FnMut(&str, &str, &str)>(
4331        &self,
4332        graph: Option<&str>,
4333        s: Option<&str>,
4334        p: Option<&str>,
4335        o: Option<&str>,
4336        mut f: F,
4337    ) {
4338        let index = match graph {
4339            None => Some(&self.index),
4340            Some(g) => self.graph_index(g),
4341        };
4342        let Some(index) = index else { return };
4343        // A bound term the dictionary does not know can match nothing, and the
4344        // index is never touched — same contract as `query_iter`/`query_batch`.
4345        let Some(pattern) = self.resolve_query_pattern(s, p, o) else {
4346            return;
4347        };
4348        let dict = &self.dict;
4349        // Resolve in fixed-size batches with ONE coalesced dictionary fault per
4350        // batch — the same `prefetch_terms` call `dump_batch` makes, for the
4351        // same reason.
4352        //
4353        // This used to be `Dictionary::prefetch_all` up front, on the argument
4354        // that a full dump reaches every term anyway so the bytes are owed
4355        // either way. That argument only holds for a dump of EVERYTHING, and
4356        // this takes a graph AND a pattern. Measured on `cordis.rete` (801 MB,
4357        // a 417 MB dictionary, six named graphs), lazily opened:
4358        //
4359        //   dump_each(Some(fundingschemes))  417 MB read, 2510 MB peak RSS
4360        //                              →     173 MB read,  619 MB peak RSS
4361        //   dump_each(None) — the EMPTY default graph, which is the first thing
4362        //   `rete export` calls on a quads file:
4363        //                                    415 MB read, 2508 MB peak RSS
4364        //                              →       0 MB read,   95 MB peak RSS
4365        //
4366        // The 2.5 GB comes from asking for every chunk at once: the bulk loader
4367        // coalesces adjacent chunks into one span, so the whole dictionary
4368        // section is materialized three times over before the first triple —
4369        // the fetched span, the per-chunk copies sliced out of it, and the
4370        // decompressed bodies.
4371        //
4372        // Be clear about what this does NOT buy. A dump of the whole default
4373        // graph still faults every chunk, and chunks stay resident once
4374        // faulted, so `rete export` of a single-graph file is unchanged:
4375        // `figshare.rete` (222 MB, a 126 MB dictionary) peaks at 874 MB lazily
4376        // either way. The resident dictionary is the floor there, not this.
4377        //
4378        // Within each window, term resolution is CHUNK-ORDERED, not row-ordered
4379        // (`Dictionary::resolve_window`, bounded-export phase 1): the window's
4380        // ids are grouped by the `(section, chunk)` they live in and each chunk
4381        // is decoded once and its runs walked once, filling every slot that
4382        // chunk owns, then the rows are emitted in the original scan order. Two
4383        // things follow. First, a window's objects — which scatter across the
4384        // object-only section for one subject — touch each chunk a single time
4385        // instead of re-faulting it per row. Second, the per-id re-walk from a
4386        // run's start that the old row-order path did for *every* id collapses
4387        // to one monotonic forward pass per run. The output is byte-for-byte
4388        // identical (same bytes per id, same emission order, same
4389        // all-three-resolved emit rule); the cap is still unlimited, so this is
4390        // structural preparation for the real bound (phase 2), plus a small
4391        // free speedup. The coalesced bulk fault (`prefetch_terms`) is
4392        // unchanged, so the read pattern and peak residency are as before.
4393        const WINDOW: usize = 4096;
4394        // The whole-window coalesced prefetch (`prefetch_terms`) materializes
4395        // every distinct chunk the window touches at once — up to ~2·WINDOW
4396        // chunks for scattered objects. Under a GENEROUS budget that transient
4397        // fits and the coalesced reads are the fast path (small/normal files,
4398        // `--in-memory`, unlimited). Under a TIGHT budget it would blow the cap
4399        // and thrash (insert-then-evict every chunk, then re-fault it in
4400        // `resolve_window`), so skip it: `resolve_window` is chunk-ordered and
4401        // holds one chunk `Arc` at a time (plan §3.5), keeping the window's
4402        // working set to a single chunk regardless of how the objects scatter.
4403        // Output is byte-identical either way — prefetch only warms the cache.
4404        let cap = dict.cache_cap();
4405        let prefetch_window =
4406            cap == u64::MAX || cap >= (WINDOW as u64) * 2 * (DICT_CHUNK_BUDGET as u64);
4407        // Under a finite budget the prefetch also goes in batches of
4408        // PREFETCH_BATCH chunks: one bulk read holds everything it fetched
4409        // (span, slices, decoded bodies) until it is all cached, which for a
4410        // scattered window was thousands of chunks — up to ~1 GB on
4411        // openaire-relation — outside the cache cap. Unlimited keeps one read
4412        // per section (the latency-optimal shape for a remote file).
4413        const PREFETCH_BATCH: usize = 64;
4414        let prefetch_batch = if cap == u64::MAX {
4415            usize::MAX
4416        } else {
4417            PREFETCH_BATCH
4418        };
4419        let mut ids: Vec<(u32, u32, u32)> = Vec::with_capacity(WINDOW);
4420        let mut nodes: Vec<u32> = Vec::with_capacity(WINDOW * 2);
4421        let mut preds: Vec<u32> = Vec::with_capacity(WINDOW);
4422        let mut resolver = crate::dictionary::WindowResolver::default();
4423        let mut flush = |ids: &mut Vec<(u32, u32, u32)>,
4424                         resolver: &mut crate::dictionary::WindowResolver,
4425                         f: &mut F| {
4426            if ids.is_empty() {
4427                return;
4428            }
4429            // Coalesced bulk fault of exactly the chunks this window needs —
4430            // only under a generous budget (see `prefetch_window` above); a
4431            // tight budget faults chunk-by-chunk inside `resolve_window`.
4432            if prefetch_window {
4433                nodes.clear();
4434                preds.clear();
4435                for &(s, p, o) in ids.iter() {
4436                    nodes.push(dict.subject_node(s));
4437                    nodes.push(dict.object_node(o));
4438                    preds.push(p);
4439                }
4440                dict.prefetch_terms_batched(&nodes, &preds, prefetch_batch);
4441            }
4442            dict.resolve_window(ids, resolver, |s, p, o| f(s, p, o));
4443            ids.clear();
4444        };
4445        for t in index.scan_iter(pattern) {
4446            ids.push(t);
4447            if ids.len() == WINDOW {
4448                flush(&mut ids, &mut resolver, &mut f);
4449            }
4450        }
4451        flush(&mut ids, &mut resolver, &mut f);
4452    }
4453
4454    /// What a [`dump_filtered_each`](Self::dump_filtered_each) over this graph
4455    /// and pattern will fetch, **before** it starts — the dump twin of
4456    /// `rete cost`'s query preview.
4457    ///
4458    /// Costed from the tile directories, so it fetches no tile. It does read the
4459    /// dictionary far enough to resolve the bound terms (that is a real,
4460    /// unavoidable cost of the dump too) and to learn whether the graph exists.
4461    pub fn dump_plan(
4462        &self,
4463        graph: Option<&str>,
4464        s: Option<&str>,
4465        p: Option<&str>,
4466        o: Option<&str>,
4467    ) -> DumpPlan {
4468        let index = match graph {
4469            None => Some(&self.index),
4470            Some(g) => self.graph_index(g),
4471        };
4472        let scan = index
4473            .zip(self.resolve_query_pattern(s, p, o))
4474            .map(|(ix, pattern)| ix.scan_plan(pattern));
4475        DumpPlan {
4476            scan,
4477            dictionary_bytes: self.header.dictionary_len,
4478        }
4479    }
4480
4481    /// The **pull** twin of [`Rete::dump_each`](Self::dump_each): every triple of a
4482    /// graph (`None` = default) as a lazy iterator of resolved terms.
4483    ///
4484    /// Same constant-memory scan — one triple decoded and resolved per `next()`,
4485    /// never a `Vec` of the whole graph — but the caller drives it, so it can
4486    /// stop early or be **suspended and resumed** across a foreign-function
4487    /// boundary. That is what the wasm/JS client's batched quad cursor needs:
4488    /// a callback cannot be paused mid-scan to hand control back to JavaScript,
4489    /// an iterator can.
4490    ///
4491    /// The dictionary is NOT prefetched: terms fault in as they are resolved, so
4492    /// stopping after a handful of triples costs a handful of dictionary reads
4493    /// rather than the whole dictionary — which on a lazy remote open of a large
4494    /// file would be gigabytes before the first triple, defeating the point of a
4495    /// pull API. [`dump_each`](Self::dump_each) does not prefetch it whole
4496    /// either; it batches the same per-term faults. Peak memory is
4497    /// O(faulted dictionary chunks + faulted index tiles), *not* O(triples).
4498    pub fn dump_iter(&self, graph: Option<&str>) -> impl Iterator<Item = TermTriple> + '_ {
4499        self.query_iter(graph, None, None, None)
4500    }
4501
4502    /// [`dump_iter`](Self::dump_iter) generalized to a triple **pattern**: the
4503    /// lazy pull form of [`query_in_graph`](Self::query_in_graph). A bound term
4504    /// unknown to the dictionary, or an unknown graph IRI, yields nothing.
4505    ///
4506    /// Same constant-memory guarantee — one triple decoded and resolved per
4507    /// `next()`, never a `Vec` of every match — and the same absence of a
4508    /// dictionary prefetch, so stopping after a handful of matches costs a
4509    /// handful of dictionary reads. `query_in_graph` is the eager twin; prefer
4510    /// this one wherever the consumer can stop early (`ASK`, `LIMIT`, an RDF4J
4511    /// `getStatements` that is about to be sliced).
4512    pub fn query_iter(
4513        &self,
4514        graph: Option<&str>,
4515        s: Option<&str>,
4516        p: Option<&str>,
4517        o: Option<&str>,
4518    ) -> impl Iterator<Item = TermTriple> + '_ {
4519        let index = match graph {
4520            None => Some(&self.index),
4521            Some(g) => self.graph_index(g),
4522        };
4523        index
4524            .zip(self.resolve_query_pattern(s, p, o))
4525            .into_iter()
4526            .flat_map(|(ix, pattern)| ix.scan_iter(pattern))
4527            .filter_map(move |(s, p, o)| {
4528                Some((
4529                    self.dict.subject_term(s)?,
4530                    self.dict.predicate_term(p)?,
4531                    self.dict.object_term(o)?,
4532                ))
4533            })
4534    }
4535
4536    /// One bounded, **resumable** slice of a pattern's matches inside one graph
4537    /// (`None` = default): `(triples, next_cursor, done)`. Start at `cursor = 0`
4538    /// and feed the returned cursor back until `done`.
4539    ///
4540    /// This is [`query_in_graph`](Self::query_in_graph) made streamable across a
4541    /// boundary that cannot hold a Rust borrow — the wasm/JVM one. The engine
4542    /// keeps no iterator alive between calls; the entire resume state is the
4543    /// opaque `u64` (see [`GraphIndex::scan_batch`]), so a client's cursor
4544    /// survives being suspended, and a cursor that is abandoned leaks nothing
4545    /// but a `u64`.
4546    ///
4547    /// The dictionary is faulted **per batch**, not whole: taking ten matches
4548    /// off the front of a 9.8-billion-quad file reads ten matches' worth of
4549    /// terms. That is the difference between `LIMIT 1` answering in bounded time
4550    /// and it reading a 30 GB dictionary first.
4551    ///
4552    /// `max_quads` is a floor (batches end on an a-group boundary), and every
4553    /// call either returns at least one row or reports `done`.
4554    pub fn query_batch(
4555        &self,
4556        graph: Option<&str>,
4557        s: Option<&str>,
4558        p: Option<&str>,
4559        o: Option<&str>,
4560        cursor: u64,
4561        max_quads: usize,
4562    ) -> (Vec<TermTriple>, u64, bool) {
4563        let index = match graph {
4564            None => &self.index,
4565            // An unknown graph IRI is an empty scan, not an error — same as
4566            // `query_in_graph` and `dump_batch`.
4567            Some(g) => match self.graph_index(g) {
4568                Some(i) => i,
4569                None => return (Vec::new(), cursor, true),
4570            },
4571        };
4572        // A bound term the dictionary does not know can match nothing, and the
4573        // index is never touched.
4574        let Some(pattern) = self.resolve_query_pattern(s, p, o) else {
4575            return (Vec::new(), cursor, true);
4576        };
4577        let (ids, next, done) = index.scan_batch(pattern, cursor, max_quads);
4578        // One coalesced dictionary fault for THIS batch — the same call
4579        // `query_in_graph` and `dump_batch` make, and the reason a partial walk
4580        // costs in proportion to what it actually read.
4581        if !ids.is_empty() {
4582            let mut nodes = Vec::with_capacity(ids.len() * 2);
4583            let mut preds = Vec::with_capacity(ids.len());
4584            for &(s, p, o) in &ids {
4585                nodes.push(self.dict.subject_node(s));
4586                nodes.push(self.dict.object_node(o));
4587                preds.push(p);
4588            }
4589            self.dict.prefetch_terms(&nodes, &preds);
4590        }
4591        let triples = ids
4592            .into_iter()
4593            .filter_map(|(s, p, o)| {
4594                Some((
4595                    self.dict.subject_term(s)?,
4596                    self.dict.predicate_term(p)?,
4597                    self.dict.object_term(o)?,
4598                ))
4599            })
4600            .collect();
4601        (triples, next, done)
4602    }
4603
4604    /// Resolve one bounded slice of a graph's triples. Returns
4605    /// `(triples, next_cursor, done)`; start with `cursor = 0` and feed the
4606    /// returned cursor back until `done`.
4607    ///
4608    /// This is the PULL half of the dump, and it exists because [`Rete::dump_each`] is
4609    /// push-based: it drives the scan itself and hands each triple to a
4610    /// callback. A client that wants to pull (a Python generator, a JS async
4611    /// iterator) has to hold the scan's stack between calls — which means a
4612    /// thread (unavailable on Pyodide and in wasm) or a self-referential struct
4613    /// holding an iterator that borrows the `Rete` stored next to it, whose
4614    /// soundness rests on drop order plus an unstated promise that this crate's
4615    /// lazily-faulted caches stay write-once. Neither is worth it for a dump.
4616    ///
4617    /// Instead the scan is made RESUMABLE: SPO tiles are ordered by subject id,
4618    /// so "every triple of subject `sid`, for ascending `sid`" visits exactly the
4619    /// tiles a full scan visits, in the same order, and the whole resume state
4620    /// collapses to one `u32`. No thread, no `unsafe`, no borrow held across
4621    /// calls — and unlike `(offset, limit)` it is O(n) overall rather than
4622    /// O(n²/limit), because nothing is ever re-scanned.
4623    ///
4624    /// `max_quads` is the FLOOR of a batch, not a hard cut: a batch always ends
4625    /// on a subject boundary, so no subject is split across two calls. Work per
4626    /// call is bounded by a probe budget too — a sparse named graph whose few
4627    /// subjects are spread over a huge id space returns early with `done = false`
4628    /// rather than grinding through absent ids, and the caller just asks again.
4629    pub fn dump_batch(
4630        &self,
4631        graph: Option<&str>,
4632        cursor: u32,
4633        max_quads: usize,
4634    ) -> (Vec<TermTriple>, u32, bool) {
4635        /// The first subject id at or after `sid` that any SPO tile can hold, or
4636        /// `None` when `sid` is past the last tile. Tiles ascend by leading
4637        /// range, so this is a binary search; it returns `sid` itself when a tile
4638        /// already covers it (an ordinary "this subject has no triples" step).
4639        fn next_tile_start(tiles: &[crate::index::Tile], sid: u32) -> Option<u32> {
4640            let i = tiles.partition_point(|t| t.leading_range().1 < sid);
4641            tiles.get(i).map(|t| t.leading_range().0.max(sid))
4642        }
4643
4644        let index = match graph {
4645            None => self.default_index(),
4646            // An unknown graph IRI is an empty dump, not an error — same as
4647            // `dump_each`.
4648            Some(g) => match self.graph_index(g) {
4649                Some(i) => i,
4650                None => return (Vec::new(), cursor, true),
4651            },
4652        };
4653        // Tile leading ranges live in the section directory, so both the span and
4654        // the gap jumps below are free on a lazy/remote open — no tile is fetched
4655        // to decide where to look.
4656        let tiles = index.tile_sections()[IndexPermutation::Spo.section_index()];
4657        let (Some(first), Some(last)) = (
4658            tiles.first().map(|t| t.leading_range().0),
4659            tiles.last().map(|t| t.leading_range().1),
4660        ) else {
4661            return (Vec::new(), cursor, true); // empty graph
4662        };
4663
4664        let mut sid = cursor.max(first);
4665        let mut probes = max_quads.saturating_mul(4).max(1 << 16);
4666        let mut ids: Vec<(u32, u32, u32)> = Vec::new();
4667        let mut exhausted = false;
4668        while sid <= last && probes > 0 {
4669            probes -= 1;
4670            let hits = index.match_pattern((Some(sid), None, None));
4671            if hits.is_empty() {
4672                // No triples at this id: jump to the next subject the index
4673                // holds (the tile's group directory knows it), instead of
4674                // stepping through absent ids one at a time. That is what keeps
4675                // a quad-index view — whose tile shared with other graphs has
4676                // the conservative range `0..=u32::MAX` — from probing four
4677                // billion ids. Only when that lookup cannot answer (a failed
4678                // fetch, already recorded) does it fall back to the directory:
4679                // if the id falls in a hole BETWEEN tiles, jump to the next
4680                // tile's first id.
4681                match index.next_leading(IndexPermutation::Spo.section_index(), sid) {
4682                    Some(next) if next > sid => {
4683                        sid = next;
4684                        continue;
4685                    }
4686                    None if !index.load_incomplete() => {
4687                        exhausted = true;
4688                        break;
4689                    }
4690                    _ => {}
4691                }
4692                match next_tile_start(tiles, sid) {
4693                    None => {
4694                        exhausted = true;
4695                        break;
4696                    }
4697                    Some(next) if next > sid => {
4698                        sid = next;
4699                        continue;
4700                    }
4701                    Some(_) => {}
4702                }
4703            } else {
4704                ids.extend(hits);
4705            }
4706            if sid == u32::MAX {
4707                exhausted = true;
4708                break;
4709            }
4710            sid += 1;
4711            if ids.len() >= max_quads {
4712                break;
4713            }
4714        }
4715        let done = exhausted || sid > last;
4716
4717        // One coalesced dictionary fault for this batch, then resolve.
4718        // Deliberately NOT `Dictionary::prefetch_all` (what `dump_each` uses):
4719        // on a lazy open that pulls the ENTIRE dictionary — gigabytes on a big
4720        // file — before the first quad, so taking five quads off the front would
4721        // cost as much as a full dump. Per-batch prefetch keeps a partial walk
4722        // proportional to what it actually read.
4723        let dict = self.dictionary();
4724        if !ids.is_empty() {
4725            let mut nodes = Vec::with_capacity(ids.len() * 2);
4726            let mut preds = Vec::with_capacity(ids.len());
4727            for &(s, p, o) in &ids {
4728                nodes.push(dict.subject_node(s));
4729                nodes.push(dict.object_node(o));
4730                preds.push(p);
4731            }
4732            dict.prefetch_terms(&nodes, &preds);
4733        }
4734        let triples = ids
4735            .into_iter()
4736            .filter_map(|(s, p, o)| {
4737                Some((
4738                    dict.subject_term(s)?,
4739                    dict.predicate_term(p)?,
4740                    dict.object_term(o)?,
4741                ))
4742            })
4743            .collect();
4744        (triples, sid, done)
4745    }
4746
4747    /// How many named graphs this dataset has. On a lazy ranged open this
4748    /// reads only the section's leading count varint — never the graphs.
4749    pub fn named_graph_count(&self) -> usize {
4750        self.named_graphs.count()
4751    }
4752
4753    /// The `i`-th named graph's quad count, when the file records it: a
4754    /// quad-index file keeps it in its graph directory, so this reads one
4755    /// directory block and never the graph's index. `None` for a per-graph
4756    /// (NAMED_GRAPHS section) file, or past the last graph.
4757    pub fn named_graph_quad_count(&self, i: usize) -> Option<u64> {
4758        match &self.named_graphs {
4759            NamedGraphsSlot::Quad(q) => q.quad_count_at(i),
4760            _ => None,
4761        }
4762    }
4763
4764    /// The `i`-th named graph as an **owned** index that is not memoised,
4765    /// when the file can make one cheaply: a quad-index file's graph of at
4766    /// most [`TRANSIENT_GRAPH_QUADS`] quads. A walk over every graph (`GRAPH
4767    /// ?g`, the union default graph, `query_quads`) evaluates each such graph
4768    /// and drops it, instead of keeping one index per graph for the life of
4769    /// the open — 1.5 M of them on the OpenCitations sample. `None` for a
4770    /// larger graph, a per-graph file, or a failed read: use
4771    /// [`named_graph_at`](Self::named_graph_at) then.
4772    pub fn named_graph_transient(&self, i: usize) -> Option<GraphIndex> {
4773        match &self.named_graphs {
4774            NamedGraphsSlot::Quad(q) => q.transient_view(i, TRANSIENT_GRAPH_QUADS),
4775            _ => None,
4776        }
4777    }
4778
4779    /// The ordinals (ascending, as [`named_graph_at`](Self::named_graph_at)
4780    /// counts them) of the named graphs holding at least one quad whose
4781    /// subject is one of `subjects` (subject ids), for a quad-index file: one
4782    /// sequential pass over its graph-leading SPO tiles, never a view per
4783    /// graph. `None` for a per-graph file, which has no such pass, or on a
4784    /// failed read (recorded, see [`index_incomplete`](Self::index_incomplete)).
4785    pub(crate) fn named_graphs_with_subjects(
4786        &self,
4787        subjects: &std::collections::BTreeSet<u32>,
4788    ) -> Option<Vec<usize>> {
4789        match &self.named_graphs {
4790            NamedGraphsSlot::Quad(q) => q.graphs_with_subjects(subjects),
4791            _ => None,
4792        }
4793    }
4794
4795    /// Deduplicated quads across every named graph, when the file records it
4796    /// (a quad-index file, from its preamble: one small read). `None` for a
4797    /// per-graph file — there it is the header's `quad_count` minus the
4798    /// default graph's triples.
4799    pub fn named_quad_count(&self) -> Option<u64> {
4800        match &self.named_graphs {
4801            NamedGraphsSlot::Quad(q) => Some(q.quad_count()),
4802            _ => None,
4803        }
4804    }
4805
4806    /// The `i`-th named graph's IRI (stored order). On a lazy ranged open this
4807    /// walks entry HEADERS up to `i` — it never decodes a graph's index.
4808    pub fn named_graph_name_at(&self, i: usize) -> Option<&str> {
4809        self.named_graphs.name_at(&self.dict, i, false)
4810    }
4811
4812    /// The `i`-th named graph as `(iri, index)`. On a lazy ranged open the
4813    /// index is fetched and decoded on first access and memoised; check
4814    /// [`index_incomplete`](Self::index_incomplete) after evaluating.
4815    pub fn named_graph_at(&self, i: usize) -> Option<(&str, &GraphIndex)> {
4816        self.named_graphs.graph_at(&self.dict, i, false)
4817    }
4818
4819    /// [`named_graph_name_at`](Self::named_graph_name_at) under a declared
4820    /// demand: `exhaustive = true` tells a lazy walk the caller will visit
4821    /// every graph, so it may read the section in bulk chunks. Purely a fetch
4822    /// strategy — results are identical either way. Only the SPARQL evaluator
4823    /// sets it, from shapes where full consumption is provable.
4824    pub(crate) fn named_graph_name_at_demand(&self, i: usize, exhaustive: bool) -> Option<&str> {
4825        self.named_graphs.name_at(&self.dict, i, exhaustive)
4826    }
4827
4828    /// [`named_graph_at`](Self::named_graph_at) under a declared demand: see
4829    /// [`named_graph_name_at_demand`](Self::named_graph_name_at_demand).
4830    pub(crate) fn named_graph_at_demand(
4831        &self,
4832        i: usize,
4833        exhaustive: bool,
4834    ) -> Option<(&str, &GraphIndex)> {
4835        self.named_graphs.graph_at(&self.dict, i, exhaustive)
4836    }
4837
4838    /// IRIs of the named graphs in this dataset (the default graph is unnamed).
4839    /// On a lazy ranged open this walks the whole directory (headers only) —
4840    /// prefer [`named_graph_count`](Self::named_graph_count) when the number
4841    /// is all that's needed.
4842    pub fn graph_names(&self) -> Vec<&str> {
4843        (0..self.named_graphs.count())
4844            .filter_map(|i| self.named_graphs.name_at(&self.dict, i, false))
4845            .collect()
4846    }
4847
4848    /// The permutation index of a named graph, or `None` if absent.
4849    pub fn graph_index(&self, iri: &str) -> Option<&GraphIndex> {
4850        self.named_graphs.find(&self.dict, iri)
4851    }
4852
4853    /// Match a triple pattern in dictionary-ID space (subject/predicate/object
4854    /// IDs), returning integer triples — the fast path used by the BGP engine.
4855    pub fn match_ids(
4856        &self,
4857        pattern: (Option<u32>, Option<u32>, Option<u32>),
4858    ) -> Vec<(u32, u32, u32)> {
4859        self.index.match_pattern(pattern)
4860    }
4861
4862    /// All `(subject_node, object_node)` pairs for a predicate, as unified node
4863    /// IDs — no term resolution. The fast path for graph traversal.
4864    pub fn predicate_pairs(&self, predicate: &str) -> Vec<(u32, u32)> {
4865        let pid = match self.dict.predicate_id(predicate) {
4866            Some(p) => p,
4867            None => return Vec::new(),
4868        };
4869        self.index
4870            .match_pattern((None, Some(pid), None))
4871            .into_iter()
4872            .map(|(s, _p, o)| (self.dict.subject_node(s), self.dict.object_node(o)))
4873            .collect()
4874    }
4875
4876    /// Open via a [`RangeReader`], fetching only the header and the named
4877    /// section ranges — never a linear scan of the whole resource. A full query
4878    /// open touches at most 4 ranges (header, dictionary, index, pyramid-meta).
4879    pub fn open_ranged<R: RangeReader>(reader: &R) -> Result<Self, FileError> {
4880        let head = reader.read_at(0, HEADER_LEN as u64)?;
4881        let header = Header::from_bytes(&head)?;
4882
4883        let dict_bytes = reader.read_at(header.dictionary_offset, header.dictionary_len)?;
4884        let dict = decode_dictionary_container(&dict_bytes, header.dict_codec)?;
4885
4886        check_named_layout(&header)?;
4887        let index_bytes = reader.read_at(header.root_dir_offset, header.root_dir_len)?;
4888        let (index, _) = decode_root_index_container(&index_bytes, &header)?;
4889        let index_section_ranges = decode_index_section_ranges(
4890            &index_bytes,
4891            header.root_dir_offset,
4892            header.perms,
4893            header.index_container_sections(),
4894        )?;
4895
4896        let pyramid = PyramidSlot::Resident(if header.pyramid_meta_len > 0 {
4897            let mb = reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?;
4898            Some(
4899                PyramidMeta::decode(&mb)
4900                    .map_err(|_| FileError::Container("malformed pyramid meta"))?,
4901            )
4902        } else {
4903            None
4904        });
4905
4906        // Fetch the whole TEXT_INDEX section resident (this opener does one range
4907        // read per section; the lazy opener below is the one that defers it).
4908        let (ti_off, ti_len) = header.text_index_payload();
4909        let text_index_bytes = if ti_len > 0 {
4910            Some(reader.read_at(ti_off, ti_len)?)
4911        } else {
4912            None
4913        };
4914        let text_index = resident_text_index_slot(text_index_bytes.as_deref(), header.block_codec)?;
4915        // LABEL_INDEX and SCHEMA: one range read each, held resident like the
4916        // other sections of this opener.
4917        let labels = SectionSlot::Resident(header.label_index_section().and_then(|s| {
4918            let b = reader.read_at(s.offset, s.length).ok()?;
4919            crate::label_index::LabelIndex::from_section(&b, header.block_codec).ok()
4920        }));
4921        let schema = SectionSlot::Resident(read_schema_with_header(reader, &header));
4922
4923        let named_graphs = NamedGraphsSlot::resident(
4924            &header,
4925            |off, len| Ok(reader.read_at(off, len)?),
4926            || {
4927                let nb = reader.read_at(header.named_graphs_offset, header.named_graphs_len)?;
4928                decode_named_graphs(&nb, header.block_codec, header.perms)
4929            },
4930        )?;
4931
4932        // The metadata section (Dataset Card) is deliberately NOT fetched here:
4933        // a ranged query open keeps to its small range budget. Use `Rete::open`
4934        // (or a dedicated card fetch) when the card is actually needed.
4935        let tile_ranges =
4936            tile_file_ranges(&index_bytes, header.root_dir_offset, &index_section_ranges);
4937        Ok(Self {
4938            header,
4939            dict,
4940            index,
4941            index_section_ranges,
4942            tile_ranges,
4943            pyramid,
4944            text_index,
4945            labels,
4946            schema,
4947            named_graphs,
4948            metadata: Vec::new(),
4949            service_client: None,
4950            service_error: std::sync::Mutex::new(None),
4951            spill_threshold: std::sync::atomic::AtomicU64::new(u64::MAX),
4952        })
4953    }
4954
4955    /// Open via an **owned** [`RangeReader`] with lazy tile faulting (tiled
4956    /// v0.2 files): fetches the header, dictionary, pyramid meta, named graphs,
4957    /// and each permutation's tile **directory** — but no default-graph tile
4958    /// payloads. Tiles fault in (one range request each) the first time a scan
4959    /// touches them, so a selective SPARQL query fetches O(touched tiles)
4960    /// bytes instead of the whole index.
4961    ///
4962    /// **Failure contract:** scans are infallible by design, so a failed tile
4963    /// fetch yields an empty tile and sets a sticky flag — after evaluating,
4964    /// callers MUST check [`index_incomplete`](Self::index_incomplete) and
4965    /// surface an error instead of the (possibly partial) results.
4966    pub fn open_ranged_lazy<R: RangeReader + Send + Sync + 'static>(
4967        reader: R,
4968    ) -> Result<Self, FileError> {
4969        let head = reader.read_at(0, HEADER_LEN as u64)?;
4970        let header = Header::from_bytes(&head)?;
4971        check_named_layout(&header)?;
4972        let reader = std::sync::Arc::new(reader);
4973        // Captured before the loader closures take the Arc: the reader's
4974        // concurrent-range fan-out, stamped onto the index for the planner.
4975        let read_concurrency = reader.concurrency();
4976
4977        // Lazily-chunked dictionary: locate the four sections, fetch each
4978        // section's header + restart table + chunk directory (small), and
4979        // fault the chunk bodies in on first term lookup.
4980        let open_dict = || ranged_chunked_dictionary(&reader, &header, [true; 4]);
4981
4982        // Locate the six index section payloads (container framing only) and
4983        // fetch just their tile directories — shared with the per-named-graph
4984        // lazy opener, which opens a large graph's container the same way.
4985        let reader_dyn: std::sync::Arc<dyn RangeReader + Send + Sync> = reader.clone();
4986        let open_index = || {
4987            open_index_container_lazy(
4988                &reader_dyn,
4989                ByteRange {
4990                    offset: header.root_dir_offset,
4991                    len: header.root_dir_len,
4992                },
4993                header.block_codec,
4994                header.has_tile_synopsis(),
4995                read_concurrency,
4996                header.perms,
4997                header.index_container_sections(),
4998            )
4999        };
5000
5001        // Each of the two is a chain of small dependent reads (a container's
5002        // sections are found by walking their length prefixes), ~8 round trips
5003        // apiece on a real file, and the header already says where both
5004        // containers start. So a reader that can keep several reads in flight
5005        // walks them at the same time: the open costs the longer chain, not
5006        // the sum. Same reads, same result; only their overlap changes.
5007        let (dict, index) = open_side_by_side(read_concurrency, open_dict, open_index);
5008        let dict = dict?;
5009        let (index, index_section_ranges, tile_ranges) = index?;
5010
5011        // The pyramid meta is large on real graphs (tens of MB) and SPARQL never
5012        // reads it, so defer its fetch: it faults in only if `pyramid()` is
5013        // called (community / pyramid_tree / inspect queries).
5014        let pyramid = ranged_pyramid_slot(&reader, &header);
5015
5016        // The TEXT_INDEX section is also deferred: a text search faults the token
5017        // table on first call (the leading varint then its compressed bytes), then
5018        // fetches individual posting lists by `(offset, len)` — never the whole
5019        // postings blob. A SPARQL query, which never searches, pays nothing.
5020        let text_index = ranged_text_index_slot(&reader, &header);
5021
5022        // LABEL_INDEX: the first prefix search reads its head and walks down.
5023        // SCHEMA: read once, on first use — a stats question, or the planner
5024        // ordering a join that needs a measured selectivity.
5025        let labels = ranged_label_slot(&reader, &header);
5026        let schema = ranged_schema_slot(&reader, &header);
5027
5028        // Named graphs are the last eagerly-fetched section standing on this
5029        // path — and on a many-graph file they dwarf everything else (67 MB
5030        // fetched and ~32k graph indexes decoded before the first query, on a
5031        // file whose queries then touch a handful of graphs). Defer them like
5032        // the pyramid and text index: nothing is read at open; a query walks
5033        // the directory and decodes only the graphs it touches.
5034        let named_graphs = if let Some(q) = header.quad_index_section() {
5035            // The quad index: nothing read at open either; the preamble on the
5036            // first named-graph touch, then only the routing parts and tiles
5037            // a query uses.
5038            NamedGraphsSlot::Quad(crate::quad::QuadNamed::new(
5039                reader_dyn,
5040                ByteRange {
5041                    offset: q.offset,
5042                    len: q.length,
5043                },
5044                header.block_codec,
5045                header.perms,
5046                read_concurrency,
5047            ))
5048        } else if header.named_graphs_len > 0 {
5049            NamedGraphsSlot::Lazy(LazyNamedGraphs::new(
5050                reader_dyn,
5051                ByteRange {
5052                    offset: header.named_graphs_offset,
5053                    len: header.named_graphs_len,
5054                },
5055                header.block_codec,
5056                header.has_tile_synopsis(),
5057                read_concurrency,
5058                header.perms,
5059            ))
5060        } else {
5061            NamedGraphsSlot::Resident(Vec::new())
5062        };
5063
5064        let rete = Self {
5065            header,
5066            dict,
5067            index,
5068            index_section_ranges,
5069            tile_ranges,
5070            pyramid,
5071            text_index,
5072            labels,
5073            schema,
5074            named_graphs,
5075            metadata: Vec::new(),
5076            service_client: None,
5077            service_error: std::sync::Mutex::new(None),
5078            spill_threshold: std::sync::atomic::AtomicU64::new(u64::MAX),
5079        };
5080        // A lazy open is the path for files too big to read whole, and the
5081        // one a long-lived session (relay, Python/R/Java clients, `rete serve`)
5082        // keeps resident across queries. Unbounded, its dictionary and tile
5083        // caches grow with everything any query ever touched: a GROUP BY over
5084        // a 9.5-billion-triple file ran a 16 GB container out of memory. Cap
5085        // them by default; a caller that wants another figure (or unlimited)
5086        // calls `set_memory_budget` again. The default is sized from the
5087        // memory this process can still use (`RETE_QUERY_BUDGET_MB` overrides
5088        // it; see `crate::sysmem`): a fixed 4096 MiB did not fit a 4 GiB
5089        // container. Not on wasm32, whose 4 GiB address space makes the default
5090        // meaningless and whose callers bound their own range budgets.
5091        #[cfg(not(target_arch = "wasm32"))]
5092        rete.set_memory_budget(match crate::sysmem::default_query_budget_mb() {
5093            0 => None,
5094            mb => Some(mb << 20),
5095        });
5096        Ok(rete)
5097    }
5098
5099    /// Did any lazy fetch (index tile or dictionary chunk) fail since this
5100    /// `Rete` was opened? When true, query results may be silently incomplete —
5101    /// callers using [`Rete::open_ranged_lazy`] must check this after
5102    /// evaluating and turn it into an error.
5103    pub fn index_incomplete(&self) -> bool {
5104        self.index.load_incomplete()
5105            || self.dict.load_incomplete()
5106            || self.named_graphs.load_incomplete()
5107    }
5108
5109    /// Forget recorded lazy-fetch failures — the start-of-evaluation reset for
5110    /// a RESIDENT session (a browser worker holding one `Rete` across many
5111    /// queries): it makes [`index_incomplete`](Self::index_incomplete) a
5112    /// per-query verdict instead of a per-open one, so a single transient
5113    /// network failure no longer fails every subsequent query on the session.
5114    /// Sound because failed tiles/chunks are never cached — the next
5115    /// evaluation simply retries the fetch.
5116    pub fn reset_load_failures(&self) {
5117        self.index.reset_load_failure();
5118        self.dict.reset_load_failure();
5119        self.named_graphs.reset_load_failures();
5120    }
5121
5122    /// Bound peak resident memory to roughly `budget` bytes by capping the
5123    /// evictable dictionary chunk cache and the default-graph index tile cache
5124    /// (bounded-export phase 2). `None` or `u64::MAX` means unlimited — nothing
5125    /// is evicted and residency is identical to a plain open, which is what
5126    /// `--in-memory` and every query/wasm path use.
5127    ///
5128    /// Sound at any cap: the dictionary and the default-graph index both fault
5129    /// their chunks/tiles through a loader, so an evicted body is simply
5130    /// re-faulted on its next touch; the export dump reads each needed body
5131    /// while holding it (the cache `Arc` handout invariant), so eviction never
5132    /// drops bytes in use. Named-graph indexes are **not** capped here — a small
5133    /// resident graph has no loader and could never reload an evicted tile — so
5134    /// they stay unlimited and are released whole per slot instead (see
5135    /// [`release_named_graph`](Self::release_named_graph)).
5136    ///
5137    /// The open's **fixed** heap — chunk and tile directories, the named-graph
5138    /// directory walked so far ([`fixed_overhead`](Self::fixed_overhead)) — is
5139    /// paid whatever the caches hold, so it comes out of the dictionary share:
5140    /// the caps plus the fixed heap stay within `budget`. It grows with the
5141    /// file's chunk/tile/graph count (~18 MB of directories on the 14.75 GiB
5142    /// openaire-relation, 88 MB with its tile directories; 224 MB on a pre-#198
5143    /// epfl-infoscience whose chunk keys are whole boundary terms). Calling
5144    /// this again after a walk of the named-graph directory (as `rete export`
5145    /// does once it has listed the graph slots) re-subtracts the larger figure.
5146    /// The dictionary share never drops below its 64 MiB floor, which is the
5147    /// only way the total can be exceeded: a file whose directories alone
5148    /// approach the budget.
5149    ///
5150    /// Returns the split actually applied (dictionary share net of the fixed
5151    /// heap), for `RETE_OPEN_DEBUG` logging.
5152    pub fn set_memory_budget(&self, budget: Option<u64>) -> MemoryBudget {
5153        let total = budget.unwrap_or(u64::MAX);
5154        self.set_spill_threshold(budget.map(spill_threshold_for));
5155        let mut split = split_memory_budget(total);
5156        let fixed = if total == u64::MAX {
5157            0
5158        } else {
5159            self.fixed_overhead().total()
5160        };
5161        if split.dict_cache != u64::MAX {
5162            split.dict_cache = split
5163                .dict_cache
5164                .saturating_sub(fixed)
5165                .max(MIN_DICT_CACHE_CAP);
5166        }
5167        self.dict.set_cache_cap(split.dict_cache);
5168        self.index.set_cache_cap(split.tile_cache);
5169        // Large named graphs (opened lazily, loader-bearing) get the same tile
5170        // cap; small resident ones stay unlimited (bounded by size + per-slot
5171        // release). Combined with `release_named_graph`, a many-graph dump holds
5172        // one graph's tiles at a time, each capped.
5173        match &self.named_graphs {
5174            NamedGraphsSlot::Lazy(lazy) => lazy.set_tile_cap(split.tile_cache),
5175            // Every quad-index view, and the tiles they share, re-fault
5176            // through loaders — on every open path — so they take the cap too.
5177            NamedGraphsSlot::Quad(q) => q.set_tile_cap(split.tile_cache),
5178            NamedGraphsSlot::Resident(_) => {}
5179        }
5180        if std::env::var("RETE_OPEN_DEBUG").is_ok() {
5181            eprintln!(
5182                "[memory-budget] total={} dict_cache={} tile_cache={} block_cache={} fixed={:.1}MiB",
5183                if total == u64::MAX {
5184                    "unlimited".to_string()
5185                } else {
5186                    format!("{}MiB", total >> 20)
5187                },
5188                fmt_cap(split.dict_cache),
5189                fmt_cap(split.tile_cache),
5190                fmt_cap(split.block_cache),
5191                fixed as f64 / (1u64 << 20) as f64,
5192            );
5193        }
5194        split
5195    }
5196
5197    /// How many bytes a query's `COUNT(DISTINCT …)` keeps in memory before it
5198    /// writes sorted runs to the temporary directory (`TMPDIR`) and counts by
5199    /// merging them; the count is exact either way. `u64::MAX` (the default of
5200    /// an eager open) never spills. [`set_memory_budget`](Self::set_memory_budget)
5201    /// sets it to an eighth of the budget, so a lazy open spills past 512 MiB
5202    /// by default.
5203    pub fn spill_threshold(&self) -> u64 {
5204        self.spill_threshold
5205            .load(std::sync::atomic::Ordering::Relaxed)
5206    }
5207
5208    /// Set [`spill_threshold`](Self::spill_threshold) without touching the
5209    /// caches — for an eager (resident) open, whose caches cannot be capped.
5210    /// `None` = never spill.
5211    pub fn set_spill_threshold(&self, bytes: Option<u64>) {
5212        self.spill_threshold.store(
5213            bytes.unwrap_or(u64::MAX),
5214            std::sync::atomic::Ordering::Relaxed,
5215        );
5216    }
5217
5218    /// The dictionary chunk cache's observability counters (decoded chunks and
5219    /// bytes, hits/misses, evictions, resident bytes) — for the bounded-export
5220    /// work-accounting tests and `RETE_OPEN_DEBUG`.
5221    pub fn dict_cache_stats(&self) -> crate::chunk_cache::CacheStats {
5222        self.dict.cache_stats()
5223    }
5224
5225    /// Heap bytes this open holds outside the bounded caches — the dictionary
5226    /// chunk directories, the index tile directories and the named-graph
5227    /// directory — for `RETE_OPEN_DEBUG` / `RETE_MEM_TRACE` accounting.
5228    pub fn fixed_overhead(&self) -> FixedOverhead {
5229        FixedOverhead {
5230            dict_directory: self.dict.directory_bytes(),
5231            index_directory: self.index.directory_bytes(),
5232            named_graphs: self.named_graphs.directory_bytes(),
5233        }
5234    }
5235
5236    /// The default-graph index tile cache's observability counters.
5237    pub fn index_cache_stats(&self) -> crate::chunk_cache::CacheStats {
5238        self.index.cache_stats()
5239    }
5240
5241    /// The current dictionary chunk-cache byte cap (`u64::MAX` = unlimited).
5242    pub fn dict_cache_cap(&self) -> u64 {
5243        self.dict.cache_cap()
5244    }
5245
5246    /// Release a named graph's decoded index after its slot has been exported,
5247    /// dropping its `GraphIndex` (and, with it, that graph's decoded tiles and
5248    /// its own tile cache) so a many-graph dump does not accumulate one resident
5249    /// index per graph — the next linear floor on many-graph files once the
5250    /// dictionary is bounded (plan §6). A no-op for the default graph, an
5251    /// unknown IRI, a resident (in-memory/eager) open, or a graph never opened.
5252    ///
5253    /// Takes `&mut self`: the export loop finishes one slot's dump (which only
5254    /// borrows `&self`) before releasing, so no outstanding borrow of the index
5255    /// exists. Correct because a released index is simply re-opened (headers +
5256    /// tile directory) on a later access — the same lazy contract as a chunk
5257    /// that was never faulted.
5258    pub fn release_named_graph(&mut self, iri: &str) {
5259        match &mut self.named_graphs {
5260            NamedGraphsSlot::Lazy(lazy) => lazy.release(iri),
5261            NamedGraphsSlot::Quad(q) => q.release(&self.dict, iri),
5262            NamedGraphsSlot::Resident(_) => {}
5263        }
5264    }
5265
5266    fn resolve_query_pattern(
5267        &self,
5268        s: Option<&str>,
5269        p: Option<&str>,
5270        o: Option<&str>,
5271    ) -> Option<Pattern> {
5272        let sid = match s {
5273            Some(t) => match self.dict.subject_id(t) {
5274                Some(id) => Some(id),
5275                None => return None,
5276            },
5277            None => None,
5278        };
5279        let pid = match p {
5280            Some(t) => match self.dict.predicate_id(t) {
5281                Some(id) => Some(id),
5282                None => return None,
5283            },
5284            None => None,
5285        };
5286        let oid = match o {
5287            Some(t) => match self.dict.object_id(t) {
5288                Some(id) => Some(id),
5289                None => return None,
5290            },
5291            None => None,
5292        };
5293        Some((sid, pid, oid))
5294    }
5295
5296    /// Evaluate a triple pattern and include the file/index provenance for every
5297    /// matched result. A bound term that is unknown to the dictionary yields no
5298    /// matches.
5299    pub fn query_with_provenance(
5300        &self,
5301        s: Option<&str>,
5302        p: Option<&str>,
5303        o: Option<&str>,
5304    ) -> Vec<TripleProvenance> {
5305        let pattern = match self.resolve_query_pattern(s, p, o) {
5306            Some(pattern) => pattern,
5307            None => return Vec::new(),
5308        };
5309
5310        let index_permutation = GraphIndex::best_permutation_in(self.header.perms, pattern);
5311        let dictionary_range = ByteRange {
5312            offset: self.header.dictionary_offset,
5313            len: self.header.dictionary_len,
5314        };
5315        let index_range = ByteRange {
5316            offset: self.header.root_dir_offset,
5317            len: self.header.root_dir_len,
5318        };
5319        let index_section_range = self.index_section_ranges[index_permutation.section_index()];
5320        let pyramid_range = (self.header.pyramid_meta_len > 0).then_some(ByteRange {
5321            offset: self.header.pyramid_meta_offset,
5322            len: self.header.pyramid_meta_len,
5323        });
5324
5325        let tiles = &self.tile_ranges[index_permutation.section_index()];
5326        self.index
5327            .match_pattern(pattern)
5328            .into_iter()
5329            .filter_map(|(s, p, o)| {
5330                let terms = (
5331                    self.dict.subject_term(s)?,
5332                    self.dict.predicate_term(p)?,
5333                    self.dict.object_term(o)?,
5334                );
5335                // The physical tile holding this match: the one whose
5336                // leading-id range covers the match's permuted leading id.
5337                let a = index_permutation.forward((s, p, o)).0;
5338                let ti = tiles.partition_point(|&(_, max_a, _)| max_a < a);
5339                let (tile, tile_range) = match tiles.get(ti) {
5340                    Some(&(min_a, _, range)) if min_a <= a => (
5341                        Some(format!("{}/{ti}", index_permutation.name())),
5342                        Some(range),
5343                    ),
5344                    _ => (None, None),
5345                };
5346                Some(TripleProvenance {
5347                    terms,
5348                    ids: (s, p, o),
5349                    graph: None,
5350                    matched_pattern: pattern,
5351                    index_permutation,
5352                    dictionary_range,
5353                    index_range,
5354                    index_section_range,
5355                    pyramid_range,
5356                    tile,
5357                    tile_range,
5358                })
5359            })
5360            .collect()
5361    }
5362
5363    /// Evaluate a triple pattern given as optional term strings, returning
5364    /// matching triples resolved back to terms. A bound term that is unknown to
5365    /// the dictionary yields no matches.
5366    pub fn query(&self, s: Option<&str>, p: Option<&str>, o: Option<&str>) -> Vec<TermTriple> {
5367        self.query_with_provenance(s, p, o)
5368            .into_iter()
5369            .map(|m| m.terms)
5370            .collect()
5371    }
5372
5373    /// Match a triple pattern **within a single graph** — `None` is the default
5374    /// graph, `Some(iri)` a named graph — resolving matches to canonical terms.
5375    /// This is [`Rete::query`] (default-graph only) generalized to any graph: the
5376    /// graph-scoped primitive a quad-aware consumer (e.g. an RDF4J `Sail`'s
5377    /// `getStatements`) needs. An unknown graph IRI, or a bound term absent from
5378    /// the shared dictionary, yields an empty result. All graphs share one
5379    /// dictionary, so the pattern resolves once against that ID space.
5380    pub fn query_in_graph(
5381        &self,
5382        graph: Option<&str>,
5383        s: Option<&str>,
5384        p: Option<&str>,
5385        o: Option<&str>,
5386    ) -> Vec<TermTriple> {
5387        let pattern = match self.resolve_query_pattern(s, p, o) {
5388            Some(pattern) => pattern,
5389            None => return Vec::new(),
5390        };
5391        let index = match graph {
5392            None => &self.index,
5393            Some(g) => match self.graph_index(g) {
5394                Some(i) => i,
5395                None => return Vec::new(),
5396            },
5397        };
5398        self.resolve_matches(index.match_pattern(pattern))
5399    }
5400
5401    /// Resolve matched id-triples to terms, with one coalesced dictionary
5402    /// fault for exactly their terms.
5403    fn resolve_matches(&self, ids: Vec<Triple>) -> Vec<TermTriple> {
5404        // One coalesced dictionary fault for THIS pattern's output, not the
5405        // whole dictionary. `prefetch_all` used to sit here, which made a
5406        // bounded `(s, p, o, g)` lookup — the primitive an RDF4J `Sail`'s
5407        // `getStatements` calls, once per named graph via `query_quads` — pay
5408        // for every term in the file: on `cordis.rete` (801 MB, a 417 MB
5409        // dictionary) a 15-row answer read 416 MB and peaked at 2.5 GB RSS.
5410        // Same call `dump_batch` makes, for the same reason.
5411        if !ids.is_empty() {
5412            let mut nodes = Vec::with_capacity(ids.len() * 2);
5413            let mut preds = Vec::with_capacity(ids.len());
5414            for &(s, p, o) in &ids {
5415                nodes.push(self.dict.subject_node(s));
5416                nodes.push(self.dict.object_node(o));
5417                preds.push(p);
5418            }
5419            self.dict.prefetch_terms(&nodes, &preds);
5420        }
5421        ids.into_iter()
5422            .filter_map(|(s, p, o)| {
5423                Some((
5424                    self.dict.subject_term(s)?,
5425                    self.dict.predicate_term(p)?,
5426                    self.dict.object_term(o)?,
5427                ))
5428            })
5429            .collect()
5430    }
5431
5432    /// Match a triple pattern across the default graph **and every named graph**,
5433    /// tagging each match with its graph (`None` = default). The quad-level
5434    /// companion to [`Rete::query`]; default-graph matches come first, then each
5435    /// named graph in stored order.
5436    pub fn query_quads(
5437        &self,
5438        s: Option<&str>,
5439        p: Option<&str>,
5440        o: Option<&str>,
5441    ) -> Vec<(TermTriple, Option<String>)> {
5442        let mut out: Vec<(TermTriple, Option<String>)> = self
5443            .query_in_graph(None, s, p, o)
5444            .into_iter()
5445            .map(|t| (t, None))
5446            .collect();
5447        let Some(pattern) = self.resolve_query_pattern(s, p, o) else {
5448            return out;
5449        };
5450        for i in 0..self.named_graphs.count() {
5451            // A quad dump visits every graph by construction — exhaustive
5452            // demand, so a lazy walk may read the section in bulk chunks.
5453            let Some(iri) = self.named_graphs.name_at(&self.dict, i, true) else {
5454                continue;
5455            };
5456            // A small quad-index graph through an owned view, dropped here:
5457            // the walk holds one view at a time, not one per graph.
5458            let owned = self.named_graph_transient(i);
5459            let index = match &owned {
5460                Some(view) => view,
5461                None => match self.named_graphs.graph_at(&self.dict, i, true) {
5462                    Some((_, g)) => g,
5463                    None => continue,
5464                },
5465            };
5466            for triple in self.resolve_matches(index.match_pattern(pattern)) {
5467                out.push((triple, Some(iri.to_string())));
5468            }
5469        }
5470        out
5471    }
5472
5473    /// Evaluate one triple pattern through a [`RangeReader`] by fetching only
5474    /// the header, the dictionary, and — for a tiled (v0.2) file — the
5475    /// selected permutation section's tile **directory** plus the tile(s) the
5476    /// bound leading id routes to; an unbound leading id fetches the section's
5477    /// tile body in one request. v0.1 files fetch the whole selected section.
5478    /// Unknown bound terms return an empty result before touching the index.
5479    pub fn query_ranged<R: RangeReader>(
5480        reader: &R,
5481        s: Option<&str>,
5482        p: Option<&str>,
5483        o: Option<&str>,
5484    ) -> Result<Vec<TermTriple>, FileError> {
5485        let routed = match route_pattern(reader, s, p, o)? {
5486            Some(routed) => routed,
5487            None => return Ok(Vec::new()),
5488        };
5489        let matches = fetch_routed_matches(reader, &routed)?;
5490        Ok(matches
5491            .into_iter()
5492            .filter_map(|(s, p, o)| {
5493                Some((
5494                    routed.dict.subject_term(s)?,
5495                    routed.dict.predicate_term(p)?,
5496                    routed.dict.object_term(o)?,
5497                ))
5498            })
5499            .collect())
5500    }
5501
5502    /// Route one triple pattern to its permutation section without fetching
5503    /// any payload bytes. Returns `false` when a bound term is unknown and the
5504    /// index was skipped.
5505    pub fn route_pattern_ranged<R: RangeReader>(
5506        reader: &R,
5507        s: Option<&str>,
5508        p: Option<&str>,
5509        o: Option<&str>,
5510    ) -> Result<bool, FileError> {
5511        Ok(route_pattern(reader, s, p, o)?.is_some())
5512    }
5513}
5514
5515/// A pattern routed to its permutation section: everything needed to fetch
5516/// matches, with no payload bytes read yet.
5517struct RoutedPattern {
5518    dict: Dictionary,
5519    pattern: Pattern,
5520    permutation: IndexPermutation,
5521    header: Header,
5522    /// Absolute byte range of the selected section's payload.
5523    section: ByteRange,
5524}
5525
5526/// Resolve a pattern against the remote dictionary and locate its permutation
5527/// section (header + dictionary + container framing only).
5528fn route_pattern<R: RangeReader>(
5529    reader: &R,
5530    s: Option<&str>,
5531    p: Option<&str>,
5532    o: Option<&str>,
5533) -> Result<Option<RoutedPattern>, FileError> {
5534    let head = reader.read_at(0, HEADER_LEN as u64)?;
5535    let header = Header::from_bytes(&head)?;
5536
5537    let dict_bytes = reader.read_at(header.dictionary_offset, header.dictionary_len)?;
5538    let dict = decode_dictionary_container(&dict_bytes, header.dict_codec)?;
5539
5540    let Some(pattern) = resolve_query_pattern(&dict, s, p, o) else {
5541        return Ok(None);
5542    };
5543    let permutation = GraphIndex::best_permutation_in(header.perms, pattern);
5544    let section = locate_container_section_ranged(
5545        reader,
5546        header.root_dir_offset,
5547        header.root_dir_len,
5548        header
5549            .perms
5550            .position(permutation)
5551            .ok_or(FileError::Container("routed to an absent permutation"))?,
5552        header.index_container_sections() as u64,
5553    )?;
5554    Ok(Some(RoutedPattern {
5555        dict,
5556        pattern,
5557        permutation,
5558        header,
5559        section,
5560    }))
5561}
5562
5563/// Fetch and scan a routed pattern's matches: read the tile directory, then only
5564/// the matching tile byte ranges (the run of covering tiles for a bound leading
5565/// id — one for ordinary groups, several for a split mega-group — the
5566/// O(matching bytes) promise).
5567fn fetch_routed_matches<R: RangeReader>(
5568    reader: &R,
5569    routed: &RoutedPattern,
5570) -> Result<Vec<Triple>, FileError> {
5571    let dir = read_tile_directory_ranged(reader, routed.section)?;
5572    let [pa, _, _] = routed.permutation.order_pattern(routed.pattern);
5573    let codec = routed.header.block_codec;
5574    let mut out = Vec::new();
5575    match pa {
5576        // Bound leading id: the run of covering tiles (several for a split
5577        // mega-group; one otherwise).
5578        Some(a) => {
5579            for e in dir.iter().filter(|e| e.min_a <= a && a <= e.max_a) {
5580                let bytes = reader.read_at(routed.section.offset + e.start, e.end - e.start)?;
5581                let tile = decompress(codec, &bytes)?;
5582                out.extend(GraphIndex::match_serialized_block(
5583                    &tile,
5584                    routed.permutation,
5585                    routed.pattern,
5586                ));
5587            }
5588        }
5589        // Unbound leading id: every tile matters — fetch the contiguous tile
5590        // body in one request and slice it.
5591        None => {
5592            if let (Some(first), Some(last)) = (dir.first(), dir.last()) {
5593                let base = first.start;
5594                let body = reader.read_at(routed.section.offset + base, last.end - base)?;
5595                for e in &dir {
5596                    let tile = decompress(
5597                        codec,
5598                        &body[(e.start - base) as usize..(e.end - base) as usize],
5599                    )?;
5600                    out.extend(GraphIndex::match_serialized_block(
5601                        &tile,
5602                        routed.permutation,
5603                        routed.pattern,
5604                    ));
5605                }
5606            }
5607        }
5608    }
5609    out.sort_unstable();
5610    Ok(out)
5611}
5612
5613fn resolve_query_pattern(
5614    dict: &Dictionary,
5615    s: Option<&str>,
5616    p: Option<&str>,
5617    o: Option<&str>,
5618) -> Option<Pattern> {
5619    let sid = match s {
5620        Some(t) => Some(dict.subject_id(t)?),
5621        None => None,
5622    };
5623    let pid = match p {
5624        Some(t) => Some(dict.predicate_id(t)?),
5625        None => None,
5626    };
5627    let oid = match o {
5628        Some(t) => Some(dict.object_id(t)?),
5629        None => None,
5630    };
5631    Some((sid, pid, oid))
5632}
5633
5634/// The lazily-chunked dictionary of a ranged open: per section, fetch the
5635/// header + chunk directory and fault the chunk bodies in on first term lookup.
5636///
5637/// `want` selects which of the four sections (0 shared, 1 subject-only,
5638/// 2 object-only, 3 predicates) are really read; a skipped one becomes an empty
5639/// section whose terms resolve to `None`. On a file written before #198 a
5640/// **directory is not small**: it carried each chunk's first term verbatim, so
5641/// the object-only section of a dataset that stores abstracts can run to
5642/// hundreds of megabytes, and fetching it is most of what a remote open costs.
5643/// Current builds key it by the shortest separator instead (a few bytes per
5644/// chunk), but that is write-side, so every already-published file still pays.
5645/// A reader that only ever resolves subjects (see [`SearchView`]) skips
5646/// sections 2 and 3 and pays none of it. [`Rete::open_ranged_lazy`] wants all
5647/// four.
5648fn ranged_chunked_dictionary<R: RangeReader + Send + Sync + 'static>(
5649    reader: &std::sync::Arc<R>,
5650    header: &Header,
5651    want: [bool; 4],
5652) -> Result<Dictionary, FileError> {
5653    // One (unlimited) cache shared across the four sections, keyed by
5654    // (section_index, chunk_index).
5655    let cache = crate::chunk_cache::ChunkCache::unlimited_arc();
5656    let mut dict_sections: Vec<crate::dict::ChunkedSection> = Vec::with_capacity(4);
5657    let mut section_ranges: [std::sync::Arc<Vec<ByteRange>>; 4] = Default::default();
5658    for si in 0..4 {
5659        if !want[si] {
5660            dict_sections.push(crate::dict::ChunkedSection::from_parts(
5661                crate::dict::SectionMeta {
5662                    term_count: 0,
5663                    restart_interval: 1,
5664                    restart_offsets: Vec::new(),
5665                },
5666                Vec::new(),
5667                None,
5668                cache.clone(),
5669                si as u8,
5670            ));
5671            continue;
5672        }
5673        let section = locate_container_section_ranged(
5674            reader.as_ref(),
5675            header.dictionary_offset,
5676            header.dictionary_len,
5677            si,
5678            4,
5679        )?;
5680        let (meta, entries) = read_dict_dir_ranged(reader.as_ref(), section)?;
5681        // One copy of the chunk ranges, shared by this section's loaders and
5682        // the dictionary's cross-section batch loader.
5683        let ranges: std::sync::Arc<Vec<ByteRange>> = std::sync::Arc::new(
5684            entries
5685                .iter()
5686                .map(|e| ByteRange {
5687                    offset: section.offset + e.start,
5688                    len: (e.end - e.start),
5689                })
5690                .collect(),
5691        );
5692        section_ranges[si] = ranges.clone();
5693        let chunks: Vec<crate::dict::SectionChunk> = entries
5694            .into_iter()
5695            .map(|e| crate::dict::SectionChunk::new(e.first_run, e.key, e.body_start))
5696            .collect();
5697        let chunk_reader = reader.clone();
5698        let codec = header.dict_codec;
5699        let loader_ranges = ranges.clone();
5700        let loader: crate::dict::ChunkLoader = Box::new(move |ci| {
5701            let range = loader_ranges.get(ci)?;
5702            let bytes = chunk_reader.read_at(range.offset, range.len).ok()?;
5703            decompress(codec, &bytes).ok()
5704        });
5705        // Full-section sweeps (export/dump) batch their chunk fetches:
5706        // adjacent chunk ranges coalesce into a handful of range reads.
5707        let bulk_reader = reader.clone();
5708        let bulk: crate::dict::ChunkBulkLoader = Box::new(move |cis| {
5709            let want: Option<Vec<ByteRange>> =
5710                cis.iter().map(|&ci| ranges.get(ci).copied()).collect();
5711            let blobs = read_coalesced(bulk_reader.as_ref(), &want?, DICT_COALESCE_GAP)?;
5712            blobs.iter().map(|b| decompress(codec, b).ok()).collect()
5713        });
5714        dict_sections.push(
5715            crate::dict::ChunkedSection::from_parts(
5716                meta,
5717                chunks,
5718                Some(loader),
5719                cache.clone(),
5720                si as u8,
5721            )
5722            .with_bulk_loader(bulk),
5723        );
5724    }
5725    let dict_arr: [crate::dict::ChunkedSection; 4] = dict_sections
5726        .try_into()
5727        .map_err(|_| FileError::Container("expected 4 dictionary sections"))?;
5728    // Decoding a result touches several sections at once; this fetches all
5729    // their missing chunks in one batch (pairs arrive section by section in
5730    // ascending chunk order, so ascending in the file, as coalescing wants).
5731    let multi_reader = reader.clone();
5732    let codec = header.dict_codec;
5733    let multi: crate::dict::MultiChunkBulkLoader = Box::new(move |pairs| {
5734        let want: Option<Vec<ByteRange>> = pairs
5735            .iter()
5736            .map(|&(si, ci)| section_ranges.get(si as usize)?.get(ci).copied())
5737            .collect();
5738        let blobs = read_coalesced(multi_reader.as_ref(), &want?, DICT_COALESCE_GAP)?;
5739        blobs.iter().map(|b| decompress(codec, b).ok()).collect()
5740    });
5741    Ok(Dictionary::from_chunked_sections(dict_arr).with_multi_bulk_loader(multi))
5742}
5743
5744/// Run two independent steps of an open: at the same time when the reader can
5745/// keep several reads in flight (`concurrency > 1`) on a target with threads,
5746/// else one after the other. Either way both run to completion and their
5747/// results come back as they would inline; a panic in either propagates.
5748#[cfg(not(target_arch = "wasm32"))]
5749fn open_side_by_side<A, B: Send>(
5750    concurrency: usize,
5751    a: impl FnOnce() -> A,
5752    b: impl FnOnce() -> B + Send,
5753) -> (A, B) {
5754    if concurrency <= 1 {
5755        return (a(), b());
5756    }
5757    std::thread::scope(|scope| {
5758        let hb = scope.spawn(b);
5759        let ra = a();
5760        let rb = hb
5761            .join()
5762            .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
5763        (ra, rb)
5764    })
5765}
5766
5767/// wasm32 has no threads here: always one after the other.
5768#[cfg(target_arch = "wasm32")]
5769fn open_side_by_side<A, B>(
5770    _concurrency: usize,
5771    a: impl FnOnce() -> A,
5772    b: impl FnOnce() -> B,
5773) -> (A, B) {
5774    (a(), b())
5775}
5776
5777/// The pyramid meta is large on real graphs (tens of MB) and SPARQL never reads
5778/// it, so a ranged open defers its fetch: it faults in only if `pyramid()` is
5779/// called (community / pyramid_tree / label-prefix queries).
5780fn ranged_pyramid_slot<R: RangeReader + Send + Sync + 'static>(
5781    reader: &std::sync::Arc<R>,
5782    header: &Header,
5783) -> PyramidSlot {
5784    if header.pyramid_meta_len == 0 {
5785        return PyramidSlot::Resident(None);
5786    }
5787    let pyr_reader = reader.clone();
5788    let pyr_off = header.pyramid_meta_offset;
5789    let pyr_len = header.pyramid_meta_len;
5790    PyramidSlot::Lazy {
5791        loader: Box::new(move || {
5792            let mb = pyr_reader.read_at(pyr_off, pyr_len).ok()?;
5793            PyramidMeta::decode(&mb).ok()
5794        }),
5795        cell: std::sync::OnceLock::new(),
5796    }
5797}
5798
5799/// The deferred TEXT_INDEX of a ranged open: a text search faults the token
5800/// table on first call (the leading varint then its compressed bytes), then
5801/// fetches individual posting lists by `(offset, len)` — never the whole
5802/// postings blob. A caller that never searches pays nothing.
5803fn ranged_text_index_slot<R: RangeReader + Send + Sync + 'static>(
5804    reader: &std::sync::Arc<R>,
5805    header: &Header,
5806) -> TextIndexSlot {
5807    let (ti_off, ti_len) = header.text_index_payload();
5808    if ti_len == 0 {
5809        return TextIndexSlot::Resident {
5810            index: None,
5811            token_table_len: None,
5812        };
5813    }
5814    let ti_reader = reader.clone();
5815    let probe_reader = reader.clone();
5816    let codec = header.block_codec;
5817    TextIndexSlot::Lazy {
5818        loader: Box::new(move || {
5819            // The section opens with `varint token_table_len`; read enough to
5820            // decode it (a uvarint is ≤ 10 bytes), then fetch the varint + the
5821            // compressed token table as one prefix range.
5822            let prefix_len = read_token_table_len(&*ti_reader, ti_off, ti_len)?;
5823            let prefix = ti_reader.read_at(ti_off, prefix_len).ok()?;
5824            let postings_base = crate::text_index::TextIndex::postings_base(&prefix)? as u64;
5825            let postings_abs = ti_off + postings_base;
5826            let pr = ti_reader.clone();
5827            let posting_loader =
5828                Box::new(move |off: u64, len: u64| pr.read_at(postings_abs + off, len).ok());
5829            crate::text_index::TextIndex::from_token_table(&prefix, codec, posting_loader).ok()
5830        }),
5831        cell: std::sync::OnceLock::new(),
5832        // The same leading varint the loader starts from, asked on its own so a
5833        // caller can *state* the cost of a first search without paying it.
5834        token_table: Box::new(move || read_token_table_len(&*probe_reader, ti_off, ti_len)),
5835        token_table_cell: std::sync::OnceLock::new(),
5836    }
5837}
5838
5839/// The `(label, subject term)` pairs of a legacy (pyramid-block) label search.
5840/// The hits' subjects are scattered over the dictionary, so their chunks are
5841/// faulted in one batch first (one round trip on a remote file, as
5842/// [`text_search_in`] does for full-text hits), then decoded in one
5843/// chunk-ordered pass. A subject that does not resolve drops its hit, as
5844/// before; the order is the search's.
5845fn resolve_label_hits(
5846    dict: &Dictionary,
5847    hits: Vec<&crate::meta::LabelEntry>,
5848) -> Vec<(String, String)> {
5849    let ids: Vec<u32> = hits.iter().map(|e| e.subject).collect();
5850    dict.prefetch_subject_terms(&ids);
5851    hits.iter()
5852        .zip(dict.subject_terms(&ids))
5853        .filter_map(|(e, iri)| Some((e.label.clone(), iri?)))
5854        .collect()
5855}
5856
5857/// Full-text search over `ti`, resolving the matched subject ids through `dict`.
5858/// The engine behind [`Rete::text_search`] and [`SearchView::text_search`].
5859fn text_search_in(
5860    ti: Option<&crate::text_index::TextIndex>,
5861    dict: &Dictionary,
5862    words: &[&str],
5863    prefix: Option<&str>,
5864    limit: usize,
5865) -> Vec<String> {
5866    let Some(ti) = ti else {
5867        return Vec::new();
5868    };
5869    // Each query word is tokenized exactly as at build time (so "Glucose"
5870    // matches the stored "glucose"); a word that splits into several tokens
5871    // requires all of them. AND across every required token + the prefix.
5872    let mut acc: Option<Vec<u32>> = None;
5873    if let Some(p) = prefix {
5874        acc = Some(ti.prefix(&p.to_lowercase()));
5875    }
5876    for w in words {
5877        for tok in crate::text_index::tokenize(w) {
5878            let posting = ti.lookup(&tok);
5879            acc = Some(match acc {
5880                Some(a) => intersect_sorted(&a, &posting),
5881                None => posting,
5882            });
5883            if acc.as_ref().is_some_and(|a| a.is_empty()) {
5884                return Vec::new();
5885            }
5886        }
5887    }
5888    let ids = acc.unwrap_or_default();
5889    let mut out = Vec::with_capacity(if limit > 0 {
5890        limit.min(ids.len())
5891    } else {
5892        ids.len()
5893    });
5894    // The hits' subject IRIs are scattered over many dictionary chunks: fault
5895    // the ones a page can return in one batch (on a remote file, one round
5896    // trip instead of one per chunk). Only warms the cache.
5897    let page = if limit > 0 {
5898        limit.min(ids.len())
5899    } else {
5900        ids.len()
5901    };
5902    dict.prefetch_subject_terms(&ids[..page]);
5903    for id in ids {
5904        if let Some(iri) = dict.subject_term(id) {
5905            out.push(iri);
5906            if limit > 0 && out.len() >= limit {
5907                break;
5908            }
5909        }
5910    }
5911    out
5912}
5913
5914/// A `.rete` opened for **search only**: the dictionary's chunk directories, and
5915/// the TEXT_INDEX / pyramid deferred behind their lazy slots. Nothing else.
5916///
5917/// What makes this cheap is skipping dictionary sections 2 and 3 (object-only
5918/// and predicates) — NOT skipping the index. Measured on the published
5919/// `epfl-infoscience.rete` (1.64 GB), a full [`Rete::open_ranged_lazy`] reads
5920/// 536,947,344 B, of which the six permutation tile directories are **49,940 B
5921/// in 40 reads** and the dictionary is 536,896,380 B in 36 reads: the
5922/// object-only chunk directory alone is 234,400,728 B, because that file stores
5923/// every chunk's first term verbatim. Files built since #198 key it by the
5924/// shortest separator (~600 KB on this graph); the published one still does
5925/// not, and only a rebuild changes that. This view opens sections 0 and 1
5926/// only — both search modes return subject IRIs — and costs **21,554 B in 9
5927/// reads** on the same file. It also skips the index container and defers the
5928/// TEXT_INDEX and pyramid, so it pays only for what it reads: the token table
5929/// on the first search, then one range per posting list and per dictionary
5930/// chunk holding a matched subject. This is what backs `rete search-url`.
5931///
5932/// On a file with a LABEL_INDEX section (kind 8) the dictionary is not opened
5933/// at all until a full-text search needs it: the section stores each subject's
5934/// term, so a prefix search costs the header, the section head, one directory
5935/// node per level and one data block.
5936pub struct SearchView {
5937    header: Header,
5938    dict: SectionSlot<Dictionary>,
5939    pyramid: PyramidSlot,
5940    text_index: TextIndexSlot,
5941    labels: SectionSlot<crate::label_index::LabelIndex>,
5942}
5943
5944impl SearchView {
5945    /// Open for search over any [`RangeReader`] (HTTP or local): header +
5946    /// dictionary chunk directories only (none at all on a file with a
5947    /// LABEL_INDEX, until a text search needs them), with the text index,
5948    /// label index and pyramid faulting in on first use.
5949    pub fn open_ranged<R: RangeReader + Send + Sync + 'static>(
5950        reader: R,
5951    ) -> Result<Self, FileError> {
5952        let head = reader.read_at(0, HEADER_LEN as u64)?;
5953        let header = Header::from_bytes(&head)?;
5954        let reader = std::sync::Arc::new(reader);
5955        // Sections 0 (shared) and 1 (subject-only) are the only ones a search
5956        // resolves — both modes return subject IRIs. Skipping 2 and 3 is what
5957        // makes this open cheap where a full one is not.
5958        let dict = if header.label_index_section().is_some() {
5959            let (r, h) = (reader.clone(), header.clone());
5960            SectionSlot::Lazy {
5961                loader: Box::new(move || {
5962                    ranged_chunked_dictionary(&r, &h, [true, true, false, false]).ok()
5963                }),
5964                cell: std::sync::OnceLock::new(),
5965            }
5966        } else {
5967            SectionSlot::Resident(Some(ranged_chunked_dictionary(
5968                &reader,
5969                &header,
5970                [true, true, false, false],
5971            )?))
5972        };
5973        let pyramid = ranged_pyramid_slot(&reader, &header);
5974        let text_index = ranged_text_index_slot(&reader, &header);
5975        let labels = ranged_label_slot(&reader, &header);
5976        Ok(Self {
5977            header,
5978            dict,
5979            pyramid,
5980            text_index,
5981            labels,
5982        })
5983    }
5984
5985    /// Whether the file can answer a prefix search: it carries a LABEL_INDEX
5986    /// section or a pyramid (whose meta may hold the legacy label block). A
5987    /// header read, no fault.
5988    pub fn has_labels(&self) -> bool {
5989        self.header.label_index_section().is_some() || self.header.pyramid_meta_len > 0
5990    }
5991
5992    /// Whether the file carries a LABEL_INDEX section (kind 8).
5993    pub fn has_label_index(&self) -> bool {
5994        self.header.label_index_section().is_some()
5995    }
5996
5997    /// The file header (already fetched by [`open_ranged`](Self::open_ranged)).
5998    pub fn header(&self) -> &Header {
5999        &self.header
6000    }
6001
6002    /// Whether the file carries a TEXT_INDEX section — a header read, no fault.
6003    pub fn has_text_index(&self) -> bool {
6004        self.header.text_index_len > 0
6005    }
6006
6007    /// Whether the file carries a pyramid (where the label index lives) — a
6008    /// header read, no fault.
6009    pub fn has_pyramid(&self) -> bool {
6010        self.header.pyramid_meta_len > 0
6011    }
6012
6013    /// Full-text search — see [`Rete::text_search`] for the semantics. Faults
6014    /// the token table on the first call, then only the queried postings.
6015    pub fn text_search(&self, words: &[&str], prefix: Option<&str>, limit: usize) -> Vec<String> {
6016        let Some(dict) = self.dict.get() else {
6017            return Vec::new();
6018        };
6019        text_search_in(self.text_index.index(), dict, words, prefix, limit)
6020    }
6021
6022    /// What that first search will cost — see
6023    /// [`Rete::text_index_token_table_len`]. One ≤10-byte range read, so a
6024    /// caller can quote the figure before deciding to search.
6025    pub fn text_index_token_table_len(&self) -> Option<u64> {
6026        self.text_index.token_table_len()
6027    }
6028
6029    /// Label-prefix search — see [`Rete::prefix_search`]. Reads the
6030    /// LABEL_INDEX section when the file has one; otherwise faults the pyramid
6031    /// (which carries the legacy label block), the pricier path.
6032    pub fn prefix_search(&self, prefix: &str, limit: usize) -> Vec<(String, String)> {
6033        if let Some(li) = self.labels.get() {
6034            return li.prefix_search(prefix, limit);
6035        }
6036        let pyr = match &self.pyramid {
6037            PyramidSlot::Resident(p) => p.as_ref(),
6038            PyramidSlot::Lazy { loader, cell } => cell.get_or_init(loader).as_ref(),
6039        };
6040        let (Some(pyr), Some(dict)) = (pyr, self.dict.get()) else {
6041            return Vec::new();
6042        };
6043        resolve_label_hits(dict, pyr.prefix_search(prefix, limit))
6044    }
6045}
6046
6047/// A lightweight, overview-only view of a file: the pyramid summary graph plus
6048/// just enough dictionary to label predicates. Fetched via ranges *without*
6049/// touching the (large) triple index — the "load the coarse graph first" path
6050/// from SPEC.md §7.2.
6051#[must_use]
6052pub struct SummaryView {
6053    pub round: u32,
6054    pub summary: Vec<SuperEdge>,
6055    /// The shipped `subClassOf` hierarchy (v2 schema pyramid; empty on v1 files).
6056    pub class_hierarchy: Vec<ClassNode>,
6057    /// Per-level type rollups — the leveled legend, read index-free.
6058    pub level_rollups: Vec<LevelRollup>,
6059    /// Per-level lateral class-relation graph (the non-`is-a` connections).
6060    pub level_links: Vec<LevelLinks>,
6061    /// Per-community descriptors (Phase 4 progressive refinement; may be empty).
6062    pub descriptors: Vec<CommunityDescriptor>,
6063    /// `subClassOf` cycles (v2.1; empty on older files).
6064    pub subclass_cycles: Vec<Vec<String>>,
6065    /// `owl:disjointWith` class pairs (v2.1; empty on older files).
6066    pub disjoint_pairs: Vec<(String, String)>,
6067    /// `owl:equivalentClass` class pairs (v2.1; empty on older files).
6068    pub equivalent_pairs: Vec<(String, String)>,
6069    dict: Dictionary,
6070}
6071
6072impl SummaryView {
6073    /// Read header → dictionary → pyramid-meta only (skips the index container).
6074    pub fn open_ranged<R: RangeReader>(reader: &R) -> Result<Option<Self>, FileError> {
6075        let head = reader.read_at(0, HEADER_LEN as u64)?;
6076        let header = Header::from_bytes(&head)?;
6077        if header.pyramid_meta_len == 0 {
6078            return Ok(None);
6079        }
6080
6081        let dict_bytes = reader.read_at(header.dictionary_offset, header.dictionary_len)?;
6082        let dict = decode_dictionary_container(&dict_bytes, header.dict_codec)?;
6083
6084        let mb = reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?;
6085        let mut meta =
6086            PyramidMeta::decode(&mb).map_err(|_| FileError::Container("malformed pyramid meta"))?;
6087        // A file whose schema moved to its own section (kind 9) keeps only the
6088        // community material here: take the schema fields from that section.
6089        if let Some(s) = read_schema_with_header(reader, &header) {
6090            meta.class_hierarchy = s.class_hierarchy;
6091            meta.level_rollups = s.level_rollups;
6092            meta.level_links = s.level_links;
6093            meta.subclass_cycles = s.subclass_cycles;
6094            meta.disjoint_pairs = s.disjoint_pairs;
6095            meta.equivalent_pairs = s.equivalent_pairs;
6096        }
6097
6098        Ok(Some(SummaryView {
6099            round: meta.round,
6100            summary: meta.summary,
6101            class_hierarchy: meta.class_hierarchy,
6102            level_rollups: meta.level_rollups,
6103            level_links: meta.level_links,
6104            descriptors: meta.descriptors,
6105            subclass_cycles: meta.subclass_cycles,
6106            disjoint_pairs: meta.disjoint_pairs,
6107            equivalent_pairs: meta.equivalent_pairs,
6108            dict,
6109        }))
6110    }
6111
6112    /// A schema-only view of a file **without a pyramid**, from its SCHEMA
6113    /// section (kind 9): the hierarchy, levels and axioms, with an empty
6114    /// community summary. Reads the header and the SCHEMA section only — the
6115    /// section carries its own class and predicate strings, so no dictionary.
6116    /// `Ok(None)` when the file has no SCHEMA section. Callers that need the
6117    /// summary (`cost`, federation source selection, progressive queries) use
6118    /// [`open_ranged`](Self::open_ranged), which keeps requiring a pyramid.
6119    pub fn open_schema_ranged<R: RangeReader>(reader: &R) -> Result<Option<Self>, FileError> {
6120        let head = reader.read_at(0, HEADER_LEN as u64)?;
6121        let header = Header::from_bytes(&head)?;
6122        let Some(s) = read_schema_with_header(reader, &header) else {
6123            return Ok(None);
6124        };
6125        let dict = crate::DictionaryBuilder::new().build();
6126        Ok(Some(SummaryView {
6127            round: 0,
6128            summary: Vec::new(),
6129            class_hierarchy: s.class_hierarchy,
6130            level_rollups: s.level_rollups,
6131            level_links: s.level_links,
6132            descriptors: Vec::new(),
6133            subclass_cycles: s.subclass_cycles,
6134            disjoint_pairs: s.disjoint_pairs,
6135            equivalent_pairs: s.equivalent_pairs,
6136            dict,
6137        }))
6138    }
6139
6140    /// Number of semantic-zoom levels in the schema pyramid (0 if none shipped).
6141    pub fn level_count(&self) -> usize {
6142        self.level_rollups.len()
6143    }
6144
6145    /// The type rollup at semantic level `k` (0 = coarsest/most abstract), or
6146    /// `None` if `k` is out of range. Index-free — answered from the pyramid-meta.
6147    pub fn level_rollup(&self, k: usize) -> Option<&LevelRollup> {
6148        self.level_rollups.get(k)
6149    }
6150
6151    /// Resolve a predicate ID in the summary to its term.
6152    pub fn predicate_term(&self, id: u32) -> Option<String> {
6153        self.dict.predicate_term(id)
6154    }
6155
6156    /// Exact number of triples using `predicate`, summed from the summary's
6157    /// superedge counts — answered without ever reading the triple index.
6158    pub fn predicate_total(&self, predicate: &str) -> u32 {
6159        match self.dict.predicate_id(predicate) {
6160            Some(pid) => self
6161                .summary
6162                .iter()
6163                .filter(|e| e.predicate == pid)
6164                .map(|e| e.count)
6165                .sum(),
6166            None => 0,
6167        }
6168    }
6169
6170    /// All predicates with their exact triple totals, descending by count.
6171    pub fn predicate_totals(&self) -> Vec<(String, u32)> {
6172        let mut by_pred: std::collections::BTreeMap<u32, u32> = std::collections::BTreeMap::new();
6173        for e in &self.summary {
6174            *by_pred.entry(e.predicate).or_default() += e.count;
6175        }
6176        let mut out: Vec<(String, u32)> = by_pred
6177            .into_iter()
6178            .filter_map(|(pid, c)| self.dict.predicate_term(pid).map(|t| (t, c)))
6179            .collect();
6180        out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
6181        out
6182    }
6183
6184    /// Number of communities the summary spans (distinct supernode endpoints).
6185    pub fn community_count(&self) -> usize {
6186        let mut comms = std::collections::BTreeSet::new();
6187        for e in &self.summary {
6188            comms.insert(e.s_comm);
6189            comms.insert(e.o_comm);
6190        }
6191        comms.len()
6192    }
6193
6194    /// **Index-free T-Box coherence (Tier-0).** Detect schema-level incoherent
6195    /// points purely from the shipped schema pyramid — no triple index, no
6196    /// instance data, O(ontology) regardless of graph size:
6197    /// - `subclass-cycle`: a set of classes that are mutually `rdfs:subClassOf`.
6198    /// - `unsatisfiable-class`: a class whose ancestor closure (over all parents,
6199    ///   folded through `owl:equivalentClass`) contains both ends of an
6200    ///   `owl:disjointWith` pair, so no individual can ever be one.
6201    ///
6202    /// Soundness is bounded by what the pyramid ships: the `subClassOf` hierarchy
6203    /// is capped (`MAX_HIERARCHY` in `schema_pyramid`), so on a very large ontology
6204    /// a pruned ancestor can hide an unsatisfiable class (a false *coherent*, never
6205    /// a false *incoherent*). Instance-level clashes (a node typed into disjoint
6206    /// classes, functional-property clashes) are NOT visible here — they need the
6207    /// A-Box (Tier-1/Tier-2 `reason`).
6208    pub fn tbox_coherence(&self) -> Vec<crate::reason::Inconsistency> {
6209        schema_coherence(
6210            &self.class_hierarchy,
6211            &self.subclass_cycles,
6212            &self.disjoint_pairs,
6213            &self.equivalent_pairs,
6214        )
6215    }
6216
6217    /// True when [`tbox_coherence`](Self::tbox_coherence) finds no schema-level
6218    /// incoherent point.
6219    pub fn tbox_is_coherent(&self) -> bool {
6220        self.tbox_coherence().is_empty()
6221    }
6222}
6223
6224/// Compute T-Box coherence points from the schema-pyramid fields alone — no
6225/// dictionary, no index, no instance data. Shared by [`SummaryView::tbox_coherence`]
6226/// and the dictionary-free [`read_schema_coherence_ranged`]. Emits `subclass-cycle`
6227/// and `unsatisfiable-class` (a class whose ancestor closure — over all parents,
6228/// folded through `owl:equivalentClass` — contains both ends of a disjoint pair).
6229pub fn schema_coherence(
6230    class_hierarchy: &[ClassNode],
6231    subclass_cycles: &[Vec<String>],
6232    disjoint_pairs: &[(String, String)],
6233    equivalent_pairs: &[(String, String)],
6234) -> Vec<crate::reason::Inconsistency> {
6235    use crate::reason::Inconsistency;
6236    use std::collections::{BTreeMap, BTreeSet, VecDeque};
6237    const MAX_REACH: usize = 100_000;
6238
6239    let mut out: Vec<Inconsistency> = Vec::new();
6240
6241    for cyc in subclass_cycles {
6242        let detail = if cyc.len() == 1 {
6243            format!("{} is rdfs:subClassOf itself (a cycle)", cyc[0])
6244        } else {
6245            format!(
6246                "classes {{{}}} are mutually rdfs:subClassOf (a cycle)",
6247                cyc.join(", ")
6248            )
6249        };
6250        out.push(Inconsistency {
6251            kind: "subclass-cycle",
6252            detail,
6253        });
6254    }
6255
6256    if !disjoint_pairs.is_empty() {
6257        // Upward adjacency: subClassOf parents + bidirectional equivalence.
6258        let mut adj: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
6259        for n in class_hierarchy {
6260            let e = adj.entry(n.class.as_str()).or_default();
6261            for p in &n.parents {
6262                e.push(p.as_str());
6263            }
6264        }
6265        for (a, b) in equivalent_pairs {
6266            adj.entry(a.as_str()).or_default().push(b.as_str());
6267            adj.entry(b.as_str()).or_default().push(a.as_str());
6268        }
6269
6270        // Candidate focus classes: every class named anywhere in the schema.
6271        let mut focuses: BTreeSet<&str> =
6272            class_hierarchy.iter().map(|n| n.class.as_str()).collect();
6273        for (a, b) in disjoint_pairs.iter().chain(equivalent_pairs) {
6274            focuses.insert(a.as_str());
6275            focuses.insert(b.as_str());
6276        }
6277
6278        let mut seen: BTreeSet<&str> = BTreeSet::new();
6279        for &c in &focuses {
6280            // reach(c) = {c} ∪ ancestors (capped BFS over `adj`).
6281            let mut reach: BTreeSet<&str> = BTreeSet::new();
6282            let mut q: VecDeque<&str> = VecDeque::new();
6283            reach.insert(c);
6284            q.push_back(c);
6285            while let Some(x) = q.pop_front() {
6286                if reach.len() > MAX_REACH {
6287                    break;
6288                }
6289                if let Some(ns) = adj.get(x) {
6290                    for &p in ns {
6291                        if reach.insert(p) {
6292                            q.push_back(p);
6293                        }
6294                    }
6295                }
6296            }
6297            for (x, y) in disjoint_pairs {
6298                if reach.contains(x.as_str()) && reach.contains(y.as_str()) && seen.insert(c) {
6299                    out.push(Inconsistency {
6300                        kind: "unsatisfiable-class",
6301                        detail: format!(
6302                            "{c} is a subclass of both {x} and {y}, which are \
6303                             owl:disjointWith — no individual can be a {c}"
6304                        ),
6305                    });
6306                    break;
6307                }
6308            }
6309        }
6310    }
6311
6312    out.sort_by(|a, b| (a.kind, &a.detail).cmp(&(b.kind, &b.detail)));
6313    out
6314}
6315
6316/// **Dictionary-free Tier-0 coherence read.** Fetch only the header and the
6317/// pyramid-meta range (2 small range reads) and run [`schema_coherence`] over the
6318/// schema pyramid — never touching the **dictionary** (which a literal-heavy file
6319/// makes large) or the triple index. `Ok(None)` if the file ships no pyramid.
6320///
6321/// This is what makes the Tier-0 check cheap on big graphs: the schema pyramid
6322/// carries its own class-string table, so coherence needs none of the dictionary.
6323pub fn read_schema_coherence_ranged<R: RangeReader>(
6324    reader: &R,
6325) -> Result<Option<Vec<crate::reason::Inconsistency>>, FileError> {
6326    let head = reader.read_at(0, HEADER_LEN as u64)?;
6327    let header = Header::from_bytes(&head)?;
6328    // The SCHEMA section (kind 9) first: one range, and no pyramid needed.
6329    if let Some(s) = read_schema_with_header(reader, &header) {
6330        return Ok(Some(schema_coherence(
6331            &s.class_hierarchy,
6332            &s.subclass_cycles,
6333            &s.disjoint_pairs,
6334            &s.equivalent_pairs,
6335        )));
6336    }
6337    if header.pyramid_meta_len == 0 {
6338        return Ok(None);
6339    }
6340    // Fast path: the header records the trailing schema block's length, so read ONLY
6341    // that block (at the end of pyramid-meta) — never the community summary, the
6342    // dictionary, or the index. This is what makes Tier-0 flat at any graph size.
6343    if header.schema_meta_len > 0 && (header.schema_meta_len as u64) <= header.pyramid_meta_len {
6344        let off =
6345            header.pyramid_meta_offset + header.pyramid_meta_len - header.schema_meta_len as u64;
6346        let block = reader.read_at(off, header.schema_meta_len as u64)?;
6347        let (hierarchy, cycles, disjoint, equivalent) = crate::meta::decode_schema_block(&block)
6348            .map_err(|_| FileError::Container("malformed schema block"))?;
6349        return Ok(Some(schema_coherence(
6350            &hierarchy,
6351            &cycles,
6352            &disjoint,
6353            &equivalent,
6354        )));
6355    }
6356    // Fallback (pre-v0.2.1 files with no header field): decode the whole pyramid-meta.
6357    let mb = reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?;
6358    let meta =
6359        PyramidMeta::decode(&mb).map_err(|_| FileError::Container("malformed pyramid meta"))?;
6360    Ok(Some(schema_coherence(
6361        &meta.class_hierarchy,
6362        &meta.subclass_cycles,
6363        &meta.disjoint_pairs,
6364        &meta.equivalent_pairs,
6365    )))
6366}
6367
6368/// The **schema summary** (per-class histogram + class relations at the finest
6369/// level) read over a [`RangeReader`] from the schema pyramid alone — the
6370/// index-free, range-readable source for a Schema view of a remote graph. Returns
6371/// `(classes, relations)` with `classes = [(class_iri, count)]` and `relations =
6372/// [(s_class, predicate, o_class, count)]`; `None` when the file has no schema
6373/// pyramid. Like [`read_schema_coherence_ranged`], it reads only the trailing
6374/// schema block, so it stays flat at any graph size.
6375#[allow(clippy::type_complexity)]
6376pub fn read_schema_summary_ranged<R: RangeReader>(
6377    reader: &R,
6378) -> Result<Option<(Vec<(String, u64)>, Vec<(String, String, String, u64)>)>, FileError> {
6379    let head = reader.read_at(0, HEADER_LEN as u64)?;
6380    let header = Header::from_bytes(&head)?;
6381    // The SCHEMA section (kind 9) first: one range, and no pyramid needed.
6382    let meta = if let Some(s) = read_schema_with_header(reader, &header) {
6383        PyramidMeta::new(0, Vec::new(), &[]).with_schema(
6384            Vec::new(),
6385            s.level_rollups,
6386            s.level_links,
6387            Vec::new(),
6388            Vec::new(),
6389            Vec::new(),
6390            Vec::new(),
6391        )
6392    } else {
6393        if header.pyramid_meta_len == 0 {
6394            return Ok(None);
6395        }
6396        if header.schema_meta_len > 0 && (header.schema_meta_len as u64) <= header.pyramid_meta_len
6397        {
6398            let off = header.pyramid_meta_offset + header.pyramid_meta_len
6399                - header.schema_meta_len as u64;
6400            let block = reader.read_at(off, header.schema_meta_len as u64)?;
6401            let summary = crate::meta::decode_schema_block_summary(&block)
6402                .map_err(|_| FileError::Container("malformed schema block"))?;
6403            return Ok(Some(summary));
6404        }
6405        // Fallback (pre-v0.2.1 files): decode the whole pyramid-meta, pull finest levels.
6406        let mb = reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?;
6407        PyramidMeta::decode(&mb).map_err(|_| FileError::Container("malformed pyramid meta"))?
6408    };
6409    if meta.level_rollups.is_empty() && meta.level_links.is_empty() {
6410        return Ok(None);
6411    }
6412    let classes = meta
6413        .level_rollups
6414        .iter()
6415        .max_by_key(|r| r.depth)
6416        .map(|r| r.classes.clone())
6417        .unwrap_or_default();
6418    let relations = meta
6419        .level_links
6420        .iter()
6421        .max_by_key(|l| l.depth)
6422        .map(|l| {
6423            l.links
6424                .iter()
6425                .map(|c| {
6426                    (
6427                        c.s_class.clone(),
6428                        c.predicate.clone(),
6429                        c.o_class.clone(),
6430                        c.count,
6431                    )
6432                })
6433                .collect()
6434        })
6435        .unwrap_or_default();
6436    Ok(Some((classes, relations)))
6437}
6438
6439/// Every dictionary section's chunk **routing keys**, exactly as stored, from a
6440/// finished file image. Test-only: it exists so a test in another module of
6441/// this crate (`extbuild`) can assert on the directory the writers produce
6442/// without any of this becoming public surface.
6443#[cfg(test)]
6444pub(crate) fn dict_chunk_keys_for_test(image: &[u8]) -> Vec<Vec<Vec<u8>>> {
6445    let header = Header::from_bytes(&image[..HEADER_LEN]).unwrap();
6446    let s = header.dictionary_offset as usize;
6447    let e = s + header.dictionary_len as usize;
6448    decode_container(&image[s..e], CODEC_NONE)
6449        .unwrap()
6450        .iter()
6451        .map(|payload| {
6452            let (_meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64).unwrap();
6453            entries.into_iter().map(|c| c.key).collect()
6454        })
6455        .collect()
6456}
6457
6458#[cfg(test)]
6459mod tests {
6460    use super::*;
6461    use crate::dictionary::DictionaryBuilder;
6462    use crate::index::GraphIndexBuilder;
6463
6464    /// The pure-Rust decode path (wasm, `--no-default-features`) reads a zstd
6465    /// frame back byte for byte. Guards the `ruzstd` API across upgrades.
6466    #[cfg(not(feature = "compression"))]
6467    #[test]
6468    fn ruzstd_decodes_a_zstd_frame() {
6469        let data: Vec<u8> = (0..50_000u32)
6470            .flat_map(|i| (i % 251).to_le_bytes())
6471            .collect();
6472        let frame = ruzstd::encoding::compress_to_vec(
6473            &data[..],
6474            ruzstd::encoding::CompressionLevel::Fastest,
6475        );
6476        assert!(frame.len() < data.len());
6477        assert_eq!(decompress(CODEC_ZSTD, &frame).unwrap(), data);
6478    }
6479
6480    /// A three-permutation file must survive every opener — resident, ranged,
6481    /// ranged-lazy — and answer every pattern shape with the rows the
6482    /// six-permutation twin gives, including through a named graph's own
6483    /// container. The mask is a file-level fact, so getting it to the named
6484    /// graphs' decoder is a separate thing that can be forgotten.
6485    #[test]
6486    fn three_permutation_file_round_trips_every_open_path() {
6487        use crate::index::PermSet;
6488        use crate::reader::SliceReader;
6489
6490        let build = |perms: PermSet| {
6491            let mut db = crate::DictionaryBuilder::new();
6492            let mut triples = Vec::new();
6493            for i in 0..60u32 {
6494                let (s, p, o) = (
6495                    format!("<http://ex/s{}>", i % 11),
6496                    format!("<http://ex/p{}>", i % 3),
6497                    format!("<http://ex/o{}>", i % 7),
6498                );
6499                db.observe(&s, &p, &o);
6500                triples.push((s, p, o));
6501            }
6502            let dict = db.build();
6503            let ids: Vec<(u32, u32, u32)> = triples
6504                .iter()
6505                .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
6506                .collect();
6507            let def = GraphIndexBuilder::from_triples(ids.clone())
6508                .with_tile_budget(96)
6509                .with_perms(perms)
6510                .build();
6511            let named = GraphIndexBuilder::from_triples(ids)
6512                .with_tile_budget(96)
6513                .with_perms(perms)
6514                .build();
6515            write_dataset(
6516                &dict,
6517                &def,
6518                &[("<http://ex/g>".to_string(), named)],
6519                true,
6520                &[],
6521                0,
6522            )
6523        };
6524
6525        let six = build(PermSet::ALL);
6526        let three = build(PermSet::CORE);
6527        assert!(three.len() < six.len());
6528        assert_eq!(Header::from_bytes(&three).unwrap().perms, PermSet::CORE);
6529        assert_eq!(Header::from_bytes(&six).unwrap().perms, PermSet::ALL);
6530
6531        let shapes: Vec<(Option<&str>, Option<&str>, Option<&str>)> = vec![
6532            (None, None, None),
6533            (Some("<http://ex/s3>"), None, None),
6534            (None, Some("<http://ex/p1>"), None),
6535            (None, None, Some("<http://ex/o5>")),
6536            (Some("<http://ex/s3>"), Some("<http://ex/p1>"), None),
6537            (Some("<http://ex/s3>"), None, Some("<http://ex/o5>")),
6538            (None, Some("<http://ex/p1>"), Some("<http://ex/o5>")),
6539            (
6540                Some("<http://ex/s3>"),
6541                Some("<http://ex/p1>"),
6542                Some("<http://ex/o5>"),
6543            ),
6544        ];
6545
6546        for (image, tag) in [(&six, "six"), (&three, "three")] {
6547            let resident = Rete::open(image).unwrap();
6548            let leaked: &'static [u8] = Box::leak(image.clone().into_boxed_slice());
6549            let ranged = Rete::open_ranged(&SliceReader::new(leaked)).unwrap();
6550            let lazy = Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap();
6551            for (s, p, o) in &shapes {
6552                let want: Vec<_> = {
6553                    let mut v = Rete::open(&six).unwrap().query(*s, *p, *o);
6554                    v.sort();
6555                    v
6556                };
6557                for (got, path) in [
6558                    (resident.query(*s, *p, *o), "resident"),
6559                    (ranged.query(*s, *p, *o), "ranged"),
6560                    (lazy.query(*s, *p, *o), "lazy"),
6561                ] {
6562                    let mut got = got;
6563                    got.sort();
6564                    assert_eq!(got, want, "{tag}/{path} disagreed on {:?}", (s, p, o));
6565                }
6566                // The named graph's container carries the same mask.
6567                let mut g = resident.query_in_graph(Some("<http://ex/g>"), *s, *p, *o);
6568                g.sort();
6569                assert_eq!(g, want, "{tag}/named disagreed on {:?}", (s, p, o));
6570            }
6571        }
6572
6573        // And the single-pattern ROUTED read (the `query-url` path) locates its
6574        // section by container position, not by the format's fixed six-wide
6575        // slot — the one place the two indexes differ.
6576        let leaked: &'static [u8] = Box::leak(three.clone().into_boxed_slice());
6577        let reader = SliceReader::new(leaked);
6578        let routed = Rete::query_ranged(&reader, None, None, Some("<http://ex/o5>")).unwrap();
6579        let mut want = Rete::open(&six)
6580            .unwrap()
6581            .query(None, None, Some("<http://ex/o5>"));
6582        want.sort();
6583        let mut got = routed;
6584        got.sort();
6585        assert_eq!(got, want, "routed single-pattern read on a lean file");
6586    }
6587
6588    #[test]
6589    fn read_coalesced_merges_within_gap_and_splits_beyond() {
6590        use crate::reader::{CountingReader, SliceReader};
6591        let bytes = vec![0u8; 4096];
6592        // Three 16-byte ranges: A..B gap = 32, B..C gap = 1024.
6593        let ranges = [
6594            ByteRange { offset: 0, len: 16 },
6595            ByteRange {
6596                offset: 48,
6597                len: 16,
6598            },
6599            ByteRange {
6600                offset: 1088,
6601                len: 16,
6602            },
6603        ];
6604        // Tight gap (16): nothing merges → one read per range.
6605        let r = CountingReader::new(SliceReader::new(&bytes));
6606        let out = read_coalesced(&r, &ranges, 16).unwrap();
6607        assert_eq!(out.len(), 3);
6608        assert_eq!(r.requests(), 3);
6609        // Gap 64 merges A+B (gap 32) but not C (gap 1024) → two reads.
6610        let r = CountingReader::new(SliceReader::new(&bytes));
6611        read_coalesced(&r, &ranges, 64).unwrap();
6612        assert_eq!(r.requests(), 2);
6613        // Gap 4096 merges all three into one read, over-fetching the gaps.
6614        let r = CountingReader::new(SliceReader::new(&bytes));
6615        read_coalesced(&r, &ranges, 4096).unwrap();
6616        assert_eq!(r.requests(), 1);
6617    }
6618
6619    fn build_image() -> Vec<u8> {
6620        let triples = [
6621            ("Alice", "knows", "Bob"),
6622            ("Bob", "knows", "Carol"),
6623            ("Alice", "age", "30"),
6624        ];
6625        let mut db = DictionaryBuilder::new();
6626        for (s, p, o) in triples {
6627            db.observe(s, p, o);
6628        }
6629        let dict = db.build();
6630
6631        let mut ib = GraphIndexBuilder::new();
6632        for (s, p, o) in triples {
6633            ib.push(dict.encode(s, p, o).unwrap());
6634        }
6635        let index = ib.build();
6636
6637        let (meta, levels) = build_pyramid_meta(&dict, &triples_ids(&dict), DEFAULT_TILE_BUDGET);
6638        write_file(&dict, &index, false, &meta, levels)
6639    }
6640
6641    fn triples_ids(dict: &Dictionary) -> Vec<(u32, u32, u32)> {
6642        [
6643            ("Alice", "knows", "Bob"),
6644            ("Bob", "knows", "Carol"),
6645            ("Alice", "age", "30"),
6646        ]
6647        .iter()
6648        .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
6649        .collect()
6650    }
6651
6652    #[test]
6653    fn file_round_trips_header_and_counts() {
6654        let bytes = build_image();
6655        let rete = Rete::open(&bytes).unwrap();
6656        assert_eq!(rete.header().quad_count, 3);
6657        assert!(rete.header().term_count >= 5);
6658        let expected_codec = writer_codec();
6659        assert_eq!(rete.header().dict_codec, expected_codec);
6660        assert_eq!(rete.header().block_codec, expected_codec);
6661        assert_eq!(&bytes[bytes.len() - 4..], &MAGIC); // footer marker
6662    }
6663
6664    /// A file whose index was built with a tiny tile budget (forcing many
6665    /// tiles per permutation) must round-trip through write/open and answer
6666    /// every query shape identically — through both the in-memory and the
6667    /// routed ranged read paths.
6668    #[test]
6669    fn multi_tile_file_round_trips_and_routes() {
6670        let triples: Vec<(String, String, String)> = (0..200)
6671            .map(|i| {
6672                (
6673                    format!("<http://ex/s/{i}>"),
6674                    format!("<http://ex/p/{}>", i % 5),
6675                    format!("<http://ex/o/{}>", i % 23),
6676                )
6677            })
6678            .collect();
6679        let mut db = DictionaryBuilder::new();
6680        for (s, p, o) in &triples {
6681            db.observe(s, p, o);
6682        }
6683        let dict = db.build();
6684        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
6685        for (s, p, o) in &triples {
6686            ib.push(dict.encode(s, p, o).unwrap());
6687        }
6688        let index = ib.build();
6689        assert!(
6690            index.tile_sections()[0].len() > 3,
6691            "tiny budget must force many tiles"
6692        );
6693        let bytes = write_file(&dict, &index, false, &[], 0);
6694
6695        let rete = Rete::open(&bytes).unwrap();
6696        assert_eq!(rete.header().version, crate::header::CURRENT_FORMAT_VERSION);
6697        assert_eq!(rete.query(None, None, None).len(), 200);
6698        assert_eq!(rete.query(Some("<http://ex/s/7>"), None, None).len(), 1);
6699        assert_eq!(
6700            rete.query(None, Some("<http://ex/p/3>"), None).len(),
6701            40,
6702            "predicate extent spans tiles"
6703        );
6704        assert_eq!(
6705            rete.query(None, None, Some("<http://ex/o/22>")).len(),
6706            8 // 22, 45, 68, ... < 200
6707        );
6708
6709        // Routed ranged read must agree (and only decompress matching tiles).
6710        use crate::reader::SliceReader;
6711        let reader = SliceReader::new(&bytes);
6712        let routed = Rete::query_ranged(&reader, Some("<http://ex/s/7>"), None, None).unwrap();
6713        assert_eq!(routed.len(), 1);
6714        let routed = Rete::query_ranged(&reader, None, Some("<http://ex/p/3>"), None).unwrap();
6715        assert_eq!(routed.len(), 40);
6716        let routed = Rete::query_ranged(&reader, None, None, Some("<http://ex/o/22>")).unwrap();
6717        assert_eq!(routed.len(), 8);
6718    }
6719
6720    /// The tile-synopsis trailer round-trips through encode/parse, and each parsed
6721    /// synopsis is **exactly** the tile block's own b/c zone — so the directory
6722    /// can never prune a tile the tile itself would have matched.
6723    /// Section-internal byte offsets are u64: a directory whose tiles sit past
6724    /// 4 GiB must parse with exact offsets on EVERY platform. On wasm32 (32-bit
6725    /// usize) the old parse truncated a >4 GiB section length and rejected the
6726    /// tail ("dict chunk overruns section" on the first >4 GiB dictionary —
6727    /// crossref's 5.2 GB g.obj — the playground regression this guards).
6728    #[test]
6729    fn tile_directory_offsets_survive_past_4gib() {
6730        let mut dir = Vec::new();
6731        write_uvarint(&mut dir, 2); // two tiles
6732        write_uvarint(&mut dir, 5); // tile 1: Δmin_a
6733        write_uvarint(&mut dir, 0); //         span
6734        write_uvarint(&mut dir, 3 << 30); //   len = 3 GiB
6735        write_uvarint(&mut dir, 1); // tile 2: Δmin_a
6736        write_uvarint(&mut dir, 0);
6737        write_uvarint(&mut dir, 2 << 30); //   len = 2 GiB
6738        let total = dir.len() as u64 + (3u64 << 30) + (2u64 << 30) + 64;
6739        let entries = parse_tile_directory(&dir, total).unwrap();
6740        assert_eq!(entries.len(), 2);
6741        assert_eq!(entries[1].start, dir.len() as u64 + (3u64 << 30));
6742        assert!(
6743            entries[1].end > u32::MAX as u64,
6744            "tail tile sits past 4 GiB"
6745        );
6746        // a total smaller than the tiles must still reject the directory
6747        assert!(parse_tile_directory(&dir, 1 << 20).is_err());
6748    }
6749
6750    #[test]
6751    fn tile_synopsis_trailer_round_trips() {
6752        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
6753        for i in 0..200u32 {
6754            ib.push((i, i % 7, i % 13));
6755        }
6756        let index = ib.build();
6757        let tile_count = index.tile_sections()[0].len();
6758        assert!(tile_count > 3, "tiny budget forces many tiles");
6759
6760        let payload = encode_tiled_section(&index, 0, CODEC_NONE);
6761        let dir = parse_tile_directory(&payload, payload.len() as u64).unwrap();
6762        assert_eq!(dir.len(), tile_count);
6763        // The trailer sits past the last tile; the old directory parse stops there.
6764        let trailer_start = dir.iter().map(|e| e.end).max().unwrap();
6765        assert!(
6766            trailer_start < payload.len() as u64,
6767            "a trailer follows the tiles"
6768        );
6769        for e in &dir {
6770            assert!(
6771                e.end <= payload.len() as u64,
6772                "tiles still located within the payload"
6773            );
6774        }
6775        let syn = parse_tile_synopsis(&payload, trailer_start as usize, dir.len()).unwrap();
6776        for (e, (min_b, max_b, min_c, max_c)) in dir.iter().zip(syn) {
6777            let block = decompress(CODEC_NONE, &payload[e.start as usize..e.end as usize]).unwrap();
6778            let z = *crate::triples::TripleBlock::parse(&block).unwrap().zone();
6779            assert_eq!(
6780                (min_b, max_b, min_c, max_c),
6781                (z.min_b, z.max_b, z.min_c, z.max_c),
6782                "synopsis equals the tile's own zone"
6783            );
6784        }
6785    }
6786
6787    /// End-to-end safety: a synopsis-carrying file, opened **lazily** (range
6788    /// reads), must return exactly the brute-force answer for every pattern shape
6789    /// — the synopsis prune may never drop a real match.
6790    #[test]
6791    fn tile_synopsis_lazy_matches_reference_every_shape() {
6792        use crate::reader::{CountingReader, SliceReader};
6793        let triples: Vec<(String, String, String)> = (0..200u32)
6794            .map(|i| {
6795                (
6796                    format!("<http://ex/s/{i:04}>"),
6797                    format!("<http://ex/p/{}>", i % 7),
6798                    format!("<http://ex/o/{:04}>", i % 13),
6799                )
6800            })
6801            .collect();
6802        let mut db = DictionaryBuilder::new();
6803        for (s, p, o) in &triples {
6804            db.observe(s, p, o);
6805        }
6806        let dict = db.build();
6807        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
6808        for (s, p, o) in &triples {
6809            ib.push(dict.encode(s, p, o).unwrap());
6810        }
6811        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
6812
6813        let eager = Rete::open(&bytes).unwrap();
6814        assert!(
6815            eager.header().has_tile_synopsis(),
6816            "new files set the synopsis flag"
6817        );
6818
6819        // `open_ranged_lazy` needs a `'static` reader; leak the image (the test
6820        // process exits straight after).
6821        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
6822        let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
6823        let lazy = Rete::open_ranged_lazy(reader).unwrap();
6824
6825        let brute = |s: Option<&str>, p: Option<&str>, o: Option<&str>| {
6826            let mut v: Vec<(String, String, String)> = triples
6827                .iter()
6828                .filter(|(a, b, c)| {
6829                    s.is_none_or(|x| x == a) && p.is_none_or(|x| x == b) && o.is_none_or(|x| x == c)
6830                })
6831                .cloned()
6832                .collect();
6833            v.sort();
6834            v
6835        };
6836        // Existing + absent terms in every position (and unbound) — 4×4×4 shapes.
6837        let sv = [
6838            None,
6839            Some("<http://ex/s/0007>"),
6840            Some("<http://ex/s/0130>"),
6841            Some("<http://ex/s/9999>"),
6842        ];
6843        let pv = [
6844            None,
6845            Some("<http://ex/p/3>"),
6846            Some("<http://ex/p/6>"),
6847            Some("<http://ex/p/999>"),
6848        ];
6849        let ov = [
6850            None,
6851            Some("<http://ex/o/0000>"),
6852            Some("<http://ex/o/0012>"),
6853            Some("<http://ex/o/9999>"),
6854        ];
6855        for &s in &sv {
6856            for &p in &pv {
6857                for &o in &ov {
6858                    let mut e = eager.query(s, p, o);
6859                    e.sort();
6860                    let mut l = lazy.query(s, p, o);
6861                    l.sort();
6862                    let r = brute(s, p, o);
6863                    assert_eq!(e, r, "eager {s:?} {p:?} {o:?}");
6864                    assert_eq!(l, r, "lazy {s:?} {p:?} {o:?} — synopsis over-pruned");
6865                }
6866            }
6867        }
6868        assert!(!lazy.index_incomplete(), "no lazy fetch failed");
6869    }
6870
6871    /// Byte-0 / polyglot experiment: a real `.rete` embedded behind a large HTML
6872    /// shell (so byte 0 is `<`, not `RETE`) still opens and queries LAZILY through
6873    /// an `OffsetReader`, touching only the graph's bytes and never the shell.
6874    #[test]
6875    fn polyglot_offset_reads_lazily() {
6876        use crate::reader::{
6877            detect_polyglot_base, CountingReader, OffsetReader, SliceReader, POLYGLOT_DIGITS,
6878            POLYGLOT_MARKER,
6879        };
6880
6881        // A real .rete image, hidden behind a 50 KB HTML shell that carries the
6882        // polyglot base-offset marker in its first bytes (a browser-ignored
6883        // comment). The .rete begins right after the shell.
6884        let image = build_image();
6885        let mut shell = Vec::new();
6886        shell.extend_from_slice(b"<!DOCTYPE html><html><head><!--");
6887        shell.extend_from_slice(POLYGLOT_MARKER);
6888        let digits_at = shell.len();
6889        shell.extend_from_slice(&[b'0'; POLYGLOT_DIGITS]); // patched below
6890        shell.extend_from_slice(b"--></head><body>a web page</body></html>\n");
6891        shell.resize(50_000, b' '); // >> the .rete, to prove the shell is untouched
6892        let base = shell.len() as u64;
6893        let digits = format!("{base:0width$}", width = POLYGLOT_DIGITS);
6894        shell[digits_at..digits_at + POLYGLOT_DIGITS].copy_from_slice(digits.as_bytes());
6895
6896        let mut poly = shell;
6897        poly.extend_from_slice(&image);
6898
6899        // byte 0 is a web page, not a .rete...
6900        assert_ne!(
6901            &poly[0..4],
6902            b"RETE",
6903            "polyglot must not start with the magic"
6904        );
6905        // ...but the marker in the first header window points at the embedded .rete.
6906        let detected = detect_polyglot_base(&poly[..crate::header::HEADER_LEN]).unwrap();
6907        assert_eq!(detected, base);
6908
6909        // Open it LAZILY through the offset shim and query it.
6910        let leaked: &'static [u8] = Box::leak(poly.into_boxed_slice());
6911        let counting = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
6912        let lazy = Rete::open_ranged_lazy(OffsetReader::new(counting.clone(), detected)).unwrap();
6913
6914        // The polyglot must answer exactly like the plain .rete it embeds.
6915        let plain = Rete::open(&image).unwrap();
6916        let mut want = plain.query(None, None, None);
6917        want.sort();
6918        let mut got = lazy.query(None, None, None);
6919        got.sort();
6920        assert_eq!(
6921            got, want,
6922            "polyglot lazy query differs from the plain .rete"
6923        );
6924        assert!(!got.is_empty(), "expected some triples");
6925        assert!(!lazy.index_incomplete(), "a lazy fetch failed");
6926
6927        // It was LAZY: it read fewer bytes than the HTML shell alone, i.e. it
6928        // never touched the prefix — only the embedded .rete's own bytes.
6929        let read = counting.bytes_read();
6930        eprintln!(
6931            "polyglot lazy read: HTML shell {base} B + .rete image {} B; \
6932             the query touched only {read} B (never the shell).",
6933            image.len()
6934        );
6935        assert!(
6936            read < base,
6937            "read {read} bytes; the HTML shell alone is {base} — the reader \
6938             touched the prefix instead of only the embedded .rete"
6939        );
6940    }
6941
6942    /// Build a small file whose objects are string literals, **with** a text
6943    /// index, and return `(image, triples)`. Shared by the text-index tests.
6944    #[cfg(test)]
6945    fn build_text_indexed(triples: &[(String, String, String)]) -> Vec<u8> {
6946        let mut db = DictionaryBuilder::new();
6947        for (s, p, o) in triples {
6948            db.observe(s, p, o);
6949        }
6950        let dict = db.build();
6951        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
6952        let mut id_triples: Vec<(u32, u32, u32)> = Vec::with_capacity(triples.len());
6953        for (s, p, o) in triples {
6954            let t = dict.encode(s, p, o).unwrap();
6955            ib.push(t);
6956            id_triples.push(t);
6957        }
6958        let index = ib.build();
6959        let text_index = compute_text_index(&dict, &id_triples);
6960        assert!(
6961            !text_index.is_empty(),
6962            "literals should produce a text index"
6963        );
6964        write_dataset_with_metadata(&dict, &index, &[], false, &[], 0, &[], &text_index)
6965    }
6966
6967    /// A `--text-index` build round-trips: `text_search` returns exactly the
6968    /// subjects whose literals contain the queried word(s), with AND across words
6969    /// and token-prefix — matching a brute-force scan of the literals.
6970    #[test]
6971    fn text_index_eager_matches_brute_force() {
6972        let triples: Vec<(String, String, String)> = vec![
6973            (
6974                "<http://ex/s0>",
6975                "<http://ex/label>",
6976                "\"alpha glucose phosphate\"",
6977            ),
6978            ("<http://ex/s1>", "<http://ex/label>", "\"beta Glucose\""),
6979            ("<http://ex/s2>", "<http://ex/label>", "\"gamma fructose\""),
6980            (
6981                "<http://ex/s3>",
6982                "<http://ex/note>",
6983                "\"einstein relativity\"",
6984            ),
6985            (
6986                "<http://ex/s4>",
6987                "<http://ex/ref>",
6988                "<http://ex/not-a-literal>",
6989            ),
6990        ]
6991        .into_iter()
6992        .map(|(s, p, o)| (s.to_string(), p.to_string(), o.to_string()))
6993        .collect();
6994        let bytes = build_text_indexed(&triples);
6995        let rete = Rete::open(&bytes).unwrap();
6996        assert!(rete.has_text_index());
6997
6998        // Brute-force reference: subjects whose literal objects contain all words.
6999        let brute = |words: &[&str]| -> Vec<String> {
7000            let mut v: Vec<String> = triples
7001                .iter()
7002                .filter(|(_, _, o)| {
7003                    crate::terms::is_literal(o)
7004                        && words.iter().all(|w| {
7005                            let wl = w.to_lowercase();
7006                            crate::terms::literal_lexical(o)
7007                                .unwrap()
7008                                .split(|c: char| !c.is_alphanumeric())
7009                                .any(|t| t.to_lowercase() == wl)
7010                        })
7011                })
7012                .map(|(s, _, _)| s.clone())
7013                .collect();
7014            v.sort();
7015            v.dedup();
7016            v
7017        };
7018
7019        let mut got = rete.text_search(&["glucose"], None, 0);
7020        got.sort();
7021        assert_eq!(got, brute(&["glucose"]), "case-insensitive single word");
7022
7023        // AND across two words: only s0 has both.
7024        let mut got = rete.text_search(&["glucose", "phosphate"], None, 0);
7025        got.sort();
7026        assert_eq!(got, brute(&["glucose", "phosphate"]));
7027
7028        // A word nobody has → empty.
7029        assert!(rete.text_search(&["zzznope"], None, 0).is_empty());
7030
7031        // Token-prefix: "ein…" matches "einstein".
7032        let got = rete.text_search(&[], Some("ein"), 0);
7033        assert_eq!(got, vec!["<http://ex/s3>".to_string()]);
7034
7035        // No text index → empty, has_text_index() false.
7036        let mut db = DictionaryBuilder::new();
7037        for (s, p, o) in &triples {
7038            db.observe(s, p, o);
7039        }
7040        let dict = db.build();
7041        let mut ib = GraphIndexBuilder::new();
7042        for (s, p, o) in &triples {
7043            ib.push(dict.encode(s, p, o).unwrap());
7044        }
7045        let plain = write_dataset(&dict, &ib.build(), &[], false, &[], 0);
7046        let plain_rete = Rete::open(&plain).unwrap();
7047        assert!(!plain_rete.has_text_index());
7048        assert!(plain_rete.text_search(&["glucose"], None, 0).is_empty());
7049    }
7050
7051    /// The lazy/remote path returns the same subjects as the eager path **and**
7052    /// faults only the token table + the queried posting list — never the whole
7053    /// postings blob. A `CountingReader` proves the byte budget stays small.
7054    #[test]
7055    fn text_index_lazy_faults_only_queried_postings() {
7056        use crate::reader::{CountingReader, SliceReader};
7057        // Many subjects so the postings blob is large relative to one posting:
7058        // every subject carries "common", but only a few carry "rare".
7059        let mut triples: Vec<(String, String, String)> = (0..300u32)
7060            .map(|i| {
7061                (
7062                    format!("<http://ex/s/{i:04}>"),
7063                    "<http://ex/label>".to_string(),
7064                    format!("\"common word number {i}\""),
7065                )
7066            })
7067            .collect();
7068        for i in [3u32, 77, 250] {
7069            triples.push((
7070                format!("<http://ex/s/{i:04}>"),
7071                "<http://ex/tag>".to_string(),
7072                "\"raretoken\"".to_string(),
7073            ));
7074        }
7075        let bytes = build_text_indexed(&triples);
7076        let eager = Rete::open(&bytes).unwrap();
7077        let mut want = eager.text_search(&["raretoken"], None, 0);
7078        want.sort();
7079        assert_eq!(want.len(), 3);
7080
7081        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
7082        let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
7083        let lazy = Rete::open_ranged_lazy(reader.clone()).unwrap();
7084        // Bytes pulled by the open itself (dict dirs, index dirs, named graphs) —
7085        // the text index is deferred and not touched yet.
7086        let before = reader.bytes_read();
7087        let mut got = lazy.text_search(&["raretoken"], None, 0);
7088        got.sort();
7089        assert_eq!(got, want, "lazy search matches eager");
7090        let pulled = reader.bytes_read() - before;
7091        // The search faulted the token table + the one "raretoken" posting; it must
7092        // be far less than the whole text-index section (300 "common" postings).
7093        let ti_len = eager.header().text_index_len;
7094        assert!(
7095            pulled < ti_len,
7096            "search pulled {pulled} B but the section is {ti_len} B — faulted too much"
7097        );
7098        assert!(!lazy.index_incomplete());
7099    }
7100
7101    /// `SearchView` answers exactly what a full open answers, having read
7102    /// strictly less: it skips the permutation tile directories and — the part
7103    /// that actually costs on a literal-heavy graph — the object-only
7104    /// dictionary's chunk directory.
7105    #[test]
7106    fn search_view_matches_full_open_for_far_fewer_bytes() {
7107        use crate::reader::{CountingReader, SliceReader};
7108        // Long object literals so the object-only dictionary (and its chunk
7109        // directory) dominates the file, as on a graph that stores abstracts.
7110        let filler = "lorem ipsum dolor sit amet consectetur adipiscing elit ".repeat(20);
7111        let mut triples: Vec<(String, String, String)> = (0..300u32)
7112            .map(|i| {
7113                (
7114                    format!("<http://ex/s/{i:04}>"),
7115                    "<http://ex/abstract>".to_string(),
7116                    format!("\"{filler} number {i}\""),
7117                )
7118            })
7119            .collect();
7120        for i in [3u32, 77, 250] {
7121            triples.push((
7122                format!("<http://ex/s/{i:04}>"),
7123                "<http://ex/tag>".to_string(),
7124                "\"raretoken\"".to_string(),
7125            ));
7126        }
7127        let bytes = build_text_indexed(&triples);
7128        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
7129
7130        let full_reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
7131        let full = Rete::open_ranged_lazy(full_reader.clone()).unwrap();
7132        let mut want = full.text_search(&["raretoken"], None, 0);
7133        want.sort();
7134        assert_eq!(want.len(), 3);
7135
7136        let search_reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
7137        let view = SearchView::open_ranged(search_reader.clone()).unwrap();
7138        assert!(view.has_text_index());
7139        let mut got = view.text_search(&["raretoken"], None, 0);
7140        got.sort();
7141        assert_eq!(got, want, "search view matches the full open");
7142        assert!(
7143            search_reader.bytes_read() < full_reader.bytes_read(),
7144            "search read {} B, full open read {} B — the narrow open bought nothing",
7145            search_reader.bytes_read(),
7146            full_reader.bytes_read()
7147        );
7148
7149        // A file without a text index says so from the header alone, and the
7150        // label-prefix mode still works through the same view.
7151        let mut prefix_hits = view.prefix_search("", 5);
7152        prefix_hits.sort();
7153        assert!(!prefix_hits.is_empty() || !view.has_pyramid());
7154    }
7155
7156    /// Nothing records a dictionary chunk directory's byte length, so a ranged
7157    /// open probes for it. That probe must cost about ONE directory, not several
7158    /// — it used to re-read the whole prefix on every round, so the sum was
7159    /// ~2x the last (already over-sized) read: 537 MB of range reads to fetch
7160    /// epfl-infoscience's 234 MB object-only directory. Here the same probe runs
7161    /// against a file whose object literals are long enough that its directory
7162    /// is well past the 8 KiB header prefix, and must (a) stay inside the
7163    /// append-only bound of 2x the directory and (b) beat what the old
7164    /// re-reading loop would have spent.
7165    #[test]
7166    fn dict_chunk_directory_probe_costs_about_one_directory() {
7167        use crate::reader::{CountingReader, SliceReader};
7168        // Long literals that share a 3 KiB prefix with their neighbour, each
7169        // ending in a 1 KiB tail of its own: the WKT-polygon / JSON-blob shape
7170        // (#198's weak case). The directory stores the SHORTEST SEPARATOR per
7171        // chunk, and a separator has to reproduce the whole shared prefix — so
7172        // these keys are still ~3 KiB and the directory still runs well past
7173        // the 8 KiB header prefix, which is what gives the probe something to
7174        // measure.
7175        //
7176        // This fixture used to put the varying bytes FIRST (`"{i:04} xxxx…"`).
7177        // That shape is the one separators annihilate: its keys collapse to ~4
7178        // bytes, the whole directory to 192 B, it fits the header prefix, and
7179        // the probe never runs — the assertion below then fails naming itself.
7180        // Keep the shared prefix.
7181        let head = "x".repeat(3000);
7182        let triples: Vec<(String, String, String)> = (0..2000u32)
7183            .map(|i| {
7184                let tail = char::from(b'a' + (i % 26) as u8).to_string().repeat(1000);
7185                (
7186                    format!("<http://ex/s/{i:04}>"),
7187                    "<http://ex/abstract>".to_string(),
7188                    format!("\"{head}{i:04}{tail}\""),
7189                )
7190            })
7191            .collect();
7192        let mut db = DictionaryBuilder::new();
7193        for (s, p, o) in &triples {
7194            db.observe(s, p, o);
7195        }
7196        let dict = db.build();
7197        let mut ib = GraphIndexBuilder::new();
7198        for (s, p, o) in &triples {
7199            ib.push(dict.encode(s, p, o).unwrap());
7200        }
7201        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
7202
7203        let reader = CountingReader::new(SliceReader::new(&bytes));
7204        let header = Header::from_bytes(&reader.read_at(0, HEADER_LEN as u64).unwrap()).unwrap();
7205        // Section 2 is object-only: the one carrying the long literals.
7206        let section = locate_container_section_ranged(
7207            &reader,
7208            header.dictionary_offset,
7209            header.dictionary_len,
7210            2,
7211            4,
7212        )
7213        .unwrap();
7214
7215        let before = reader.bytes_read();
7216        let (_meta, entries) = read_dict_dir_ranged(&reader, section).unwrap();
7217        let spent = reader.bytes_read() - before;
7218
7219        // The first chunk body starts where the directory ends.
7220        let dir_end = entries[0].start;
7221        let head = reader.read_at(section.offset, 64).unwrap();
7222        let (header_len, n0) = read_uvarint(&head).unwrap();
7223        let dir_start = n0 as u64 + header_len;
7224        let dir_len = dir_end - dir_start;
7225        let dir_total = section.len - dir_start;
7226        assert!(
7227            dir_start + dir_len > 8192,
7228            "directory fits the header prefix ({dir_len} B) — the probe never runs"
7229        );
7230
7231        // What the previous loop spent: every round re-read from the start.
7232        let mut old = 0u64;
7233        let mut p = 4096u64.min(dir_total).max(1);
7234        loop {
7235            let got = p.min(dir_total);
7236            old += got;
7237            if got >= dir_len || p >= dir_total {
7238                break;
7239            }
7240            p = p.saturating_mul(2).min(dir_total);
7241        }
7242        assert!(
7243            spent <= dir_len * 2,
7244            "probe read {spent} B for a {dir_len} B directory — past the append-only bound"
7245        );
7246        assert!(
7247            spent < old,
7248            "probe read {spent} B; the re-reading loop it replaced spent {old} B"
7249        );
7250    }
7251
7252    // ---------------------------------------------------------------------
7253    // Separator routing keys (#198 option (d))
7254    // ---------------------------------------------------------------------
7255
7256    /// A verbatim-keyed twin of a chunked dictionary section: same header, same
7257    /// chunking, byte-identical compressed bodies — only every directory key
7258    /// replaced by the chunk's actual first term. That is exactly what
7259    /// `encode_chunked_dict_section` wrote before separator keys, so it is the
7260    /// control for every comparison below (and for the proptest).
7261    fn verbatim_keyed_twin(payload: &[u8], codec: u8) -> Vec<u8> {
7262        let (_meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64).unwrap();
7263        let (header_len, n0) = read_uvarint(payload).unwrap();
7264        let mut out = Vec::new();
7265        write_uvarint(&mut out, header_len);
7266        out.extend_from_slice(&payload[n0..n0 + header_len as usize]);
7267        write_uvarint(&mut out, entries.len() as u64);
7268        let mut prev_run = 0usize;
7269        for e in &entries {
7270            let body = decompress(codec, &payload[e.start as usize..e.end as usize]).unwrap();
7271            let first = crate::dict::run_first_term(&body, 0).unwrap();
7272            write_uvarint(&mut out, (e.first_run - prev_run) as u64);
7273            write_uvarint(&mut out, first.len() as u64);
7274            out.extend_from_slice(&first);
7275            write_uvarint(&mut out, e.end - e.start);
7276            prev_run = e.first_run;
7277        }
7278        for e in &entries {
7279            out.extend_from_slice(&payload[e.start as usize..e.end as usize]);
7280        }
7281        out
7282    }
7283
7284    /// Each chunk's (first term, last term), decoded from the bodies.
7285    fn chunk_bounds_of(payload: &[u8], codec: u8) -> Vec<(Vec<u8>, Vec<u8>)> {
7286        let (_meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64).unwrap();
7287        entries
7288            .iter()
7289            .map(|e| {
7290                let body = decompress(codec, &payload[e.start as usize..e.end as usize]).unwrap();
7291                (
7292                    crate::dict::run_first_term(&body, 0).unwrap(),
7293                    crate::dict::run_last_term(&body, 0, body.len()).unwrap(),
7294                )
7295            })
7296            .collect()
7297    }
7298
7299    /// A term set built to hit every shape the corpus has: plain IRIs, literals
7300    /// sharing a long prefix with their neighbour (separators are LONG there —
7301    /// the WKT/`ohm-full` weak case), literals that diverge in their first bytes
7302    /// (separators are tiny — the `epfl-infoscience` case), literals larger than
7303    /// `DICT_CHUNK_BUDGET` on their own, and multibyte UTF-8.
7304    fn adversarial_terms() -> Vec<String> {
7305        let mut terms: Vec<String> = Vec::new();
7306        for i in 0..6_000 {
7307            terms.push(format!("<http://example.org/work/{i:06}>"));
7308        }
7309        let shared = "X".repeat(900);
7310        for i in 0..4_000 {
7311            terms.push(format!("\"{shared}-{i:05}-tail\""));
7312        }
7313        let deep = "Y".repeat(3_000); // shared prefix far past any short key
7314        for i in 0..120 {
7315            terms.push(format!("\"{deep}{i:03}\""));
7316        }
7317        for i in 0..6 {
7318            // one literal larger than the 64 KiB budget: a chunk of one run
7319            terms.push(format!("\"BIG-{i:02}-{}\"", "Z".repeat(90_000)));
7320        }
7321        for i in 0..800 {
7322            terms.push(format!(
7323                "\"h\u{e9}llo-{}-\u{1F600}{i:04}\"",
7324                "\u{e9}".repeat(40)
7325            ));
7326        }
7327        for i in 0..2_000 {
7328            terms.push(format!("\"{i:05}-{}\"", "T".repeat(1_800)));
7329        }
7330        terms.sort();
7331        terms.dedup();
7332        terms
7333    }
7334
7335    fn chunked_section_of(terms: &[String]) -> Vec<u8> {
7336        let mut b = crate::dict::DictSectionBuilder::new().with_restart_interval(16);
7337        for t in terms {
7338            b.push(t.clone());
7339        }
7340        b.build()
7341    }
7342
7343    /// The invariant, asserted against the real chunk contents: the stored key
7344    /// of chunk `i` is strictly above chunk `i-1`'s LAST term and at most chunk
7345    /// `i`'s own first term, chunk 0's key is empty, and the keys ascend — the
7346    /// three facts `ChunkedSection::id`'s `partition_point` relies on.
7347    ///
7348    /// Plus the anti-regression the rename exists for: on a section like this
7349    /// the key must NOT be the first term. A writer that quietly went back to
7350    /// storing terms would pass every other test in this file — dumps and
7351    /// `term(id)` route by `first_run` and never read the key.
7352    #[test]
7353    fn chunk_directory_keys_are_separators_not_terms() {
7354        let terms = adversarial_terms();
7355        let raw = chunked_section_of(&terms);
7356        let payload = encode_chunked_dict_section(&raw, CODEC_NONE);
7357        let (_meta, entries) = parse_chunked_dict_dir(&payload, payload.len() as u64).unwrap();
7358        let bounds = chunk_bounds_of(&payload, CODEC_NONE);
7359        assert!(
7360            entries.len() > 20,
7361            "need a many-chunk section, got {}",
7362            entries.len()
7363        );
7364
7365        assert!(entries[0].key.is_empty(), "chunk 0 needs no separator");
7366        let mut key_bytes = 0usize;
7367        let mut term_bytes = 0usize;
7368        let mut differ = 0usize;
7369        for (i, e) in entries.iter().enumerate() {
7370            key_bytes += e.key.len();
7371            term_bytes += bounds[i].0.len();
7372            if e.key != bounds[i].0 {
7373                differ += 1;
7374            }
7375            assert!(
7376                e.key <= bounds[i].0,
7377                "chunk {i}: key must be <= its own first term"
7378            );
7379            if i > 0 {
7380                assert!(
7381                    e.key.as_slice() > bounds[i - 1].1.as_slice(),
7382                    "chunk {i}: key must be > the previous chunk's last term"
7383                );
7384                assert!(
7385                    e.key > entries[i - 1].key,
7386                    "chunk {i}: keys must ascend for partition_point"
7387                );
7388                // shortest: one byte past the divergence from the predecessor
7389                assert_eq!(
7390                    e.key,
7391                    crate::dict::shortest_separator(&bounds[i - 1].1, &bounds[i].0),
7392                    "chunk {i}: key is not the SHORTEST separator"
7393                );
7394            }
7395        }
7396        assert!(
7397            differ > 0,
7398            "every key equalled its first term — the writer stores terms again"
7399        );
7400        assert!(
7401            key_bytes * 4 < term_bytes,
7402            "separators bought almost nothing: {key_bytes} B vs {term_bytes} B of first terms"
7403        );
7404        eprintln!(
7405            "chunks={} first-term keys={term_bytes} B  separator keys={key_bytes} B ({:.1}x)",
7406            entries.len(),
7407            term_bytes as f64 / key_bytes.max(1) as f64
7408        );
7409    }
7410
7411    /// A separator-keyed section answers **identically** to the verbatim-keyed
7412    /// one built over the same bodies, under the same unmodified reader: every
7413    /// id, every term, and every boundary probe — the first and last term of
7414    /// each chunk, one byte either side of each, strings that fall strictly
7415    /// *between* two chunks, truncations, the empty string, and probes below
7416    /// and above the whole section.
7417    ///
7418    /// Probing `id(term)` is the entire point. A broken key (a truncation, a
7419    /// hash) leaves `term(id)`, `dump` and `export` byte-perfect — they route by
7420    /// `first_run` — and only lookups lie.
7421    #[test]
7422    fn separator_keys_route_identically_to_verbatim_first_terms() {
7423        let terms = adversarial_terms();
7424        let raw = chunked_section_of(&terms);
7425        let sep_payload = encode_chunked_dict_section(&raw, CODEC_NONE);
7426        let ver_payload = verbatim_keyed_twin(&sep_payload, CODEC_NONE);
7427
7428        // Same chunking, byte-identical bodies — only the keys differ.
7429        let (_ms, es) = parse_chunked_dict_dir(&sep_payload, sep_payload.len() as u64).unwrap();
7430        let (_mv, ev) = parse_chunked_dict_dir(&ver_payload, ver_payload.len() as u64).unwrap();
7431        assert_eq!(es.len(), ev.len());
7432        for (a, b) in es.iter().zip(ev.iter()) {
7433            assert_eq!(a.first_run, b.first_run);
7434            assert_eq!(
7435                &sep_payload[a.start as usize..a.end as usize],
7436                &ver_payload[b.start as usize..b.end as usize],
7437                "chunk bodies must be identical"
7438            );
7439        }
7440        assert!(
7441            sep_payload.len() < ver_payload.len(),
7442            "separator payload is not smaller"
7443        );
7444
7445        let sec_s = decode_chunked_dict_section(
7446            &sep_payload,
7447            CODEC_NONE,
7448            crate::chunk_cache::ChunkCache::unlimited_arc(),
7449            0,
7450        )
7451        .unwrap();
7452        let sec_v = decode_chunked_dict_section(
7453            &ver_payload,
7454            CODEC_NONE,
7455            crate::chunk_cache::ChunkCache::unlimited_arc(),
7456            0,
7457        )
7458        .unwrap();
7459
7460        // 1. every present term → the same, correct id
7461        for (i, t) in terms.iter().enumerate() {
7462            let want = Some(i as u32 + 1);
7463            assert_eq!(sec_v.id(t), want, "verbatim id() lost a term");
7464            assert_eq!(sec_s.id(t), want, "separator id() lost a term");
7465        }
7466        // 2. every id → the same term
7467        for i in 0..terms.len() as u32 {
7468            assert_eq!(sec_s.term(i + 1), sec_v.term(i + 1));
7469            assert_eq!(
7470                sec_s.term(i + 1).as_deref(),
7471                Some(terms[i as usize].as_str())
7472            );
7473        }
7474        // 3. boundary probes, from the real chunk bounds
7475        let bounds = chunk_bounds_of(&sep_payload, CODEC_NONE);
7476        let mut probes: Vec<String> = Vec::new();
7477        let push = |bytes: &[u8], probes: &mut Vec<String>| {
7478            if let Ok(s) = std::str::from_utf8(bytes) {
7479                probes.push(s.to_string());
7480            }
7481        };
7482        for (first, last) in &bounds {
7483            for base in [first, last] {
7484                push(base, &mut probes); // present
7485                let mut after = base.clone();
7486                after.push(0x01); // immediately after — between chunks when base = last
7487                push(&after, &mut probes);
7488                let mut far = base.clone();
7489                far.push(b'~');
7490                push(&far, &mut probes);
7491                if !base.is_empty() {
7492                    push(&base[..base.len() - 1], &mut probes); // truncated
7493                    let mut down = base.clone();
7494                    *down.last_mut().unwrap() = down.last().unwrap().wrapping_sub(1);
7495                    push(&down, &mut probes);
7496                    let mut up = base.clone();
7497                    *up.last_mut().unwrap() = up.last().unwrap().wrapping_add(1);
7498                    push(&up, &mut probes);
7499                }
7500            }
7501        }
7502        // 4. the stored separators themselves, and the ends of the world
7503        for e in &es {
7504            push(&e.key, &mut probes);
7505            let mut plus = e.key.clone();
7506            plus.push(0);
7507            push(&plus, &mut probes);
7508        }
7509        probes.push(String::new()); // the empty key, i.e. chunk 0's
7510        probes.push("\u{1}".to_string()); // below every term
7511        probes.push("\u{10FFFF}\u{10FFFF}".to_string()); // above every term
7512        probes.push(terms.first().unwrap().clone());
7513        probes.push(terms.last().unwrap().clone());
7514
7515        let mut hits = 0usize;
7516        for p in &probes {
7517            let (a, b) = (sec_v.id(p), sec_s.id(p));
7518            assert_eq!(
7519                a,
7520                b,
7521                "probe diverged: {:?}",
7522                p.get(..p.len().min(48)).unwrap_or(p.as_str())
7523            );
7524            hits += usize::from(a.is_some());
7525        }
7526        assert!(
7527            probes.len() > 500 && hits > 50,
7528            "probe set is too thin: {} probes, {hits} of them present",
7529            probes.len()
7530        );
7531        eprintln!(
7532            "{} boundary probes, {hits} present, all identical",
7533            probes.len()
7534        );
7535    }
7536
7537    /// The same claim as a **property**: for arbitrary term sets and arbitrary
7538    /// probes, a separator-keyed section and the verbatim-keyed twin built from
7539    /// the same input are indistinguishable through the reader. The fixed test
7540    /// above picks the shapes I thought of; this one shrinks a counterexample
7541    /// out of the ones I did not.
7542    mod separator_props {
7543        use super::*;
7544        use proptest::prelude::*;
7545
7546        /// Sorted, deduplicated term sets mixing the three shapes that decide a
7547        /// separator's length: short IRIs, literals whose payload comes BEFORE
7548        /// the discriminating bytes (long shared prefixes ⇒ long separators),
7549        /// and literals that diverge immediately (⇒ one-byte separators). Sizes
7550        /// straddle `DICT_CHUNK_BUDGET`, so single- and many-chunk sections both
7551        /// occur.
7552        fn term_set() -> impl Strategy<Value = Vec<String>> {
7553            prop::collection::vec(("[a-c]{1,6}", 0u8..3, 300usize..1200), 60..250).prop_map(
7554                |specs| {
7555                    let mut terms: Vec<String> = specs
7556                        .into_iter()
7557                        .enumerate()
7558                        .map(|(i, (core, kind, pad))| {
7559                            let block = "z".repeat(pad);
7560                            match kind {
7561                                0 => format!("<http://ex/{core}/{i:05}>"),
7562                                1 => format!("\"{block}{core}{i:05}\""),
7563                                _ => format!("\"{core}{i:05}{block}\""),
7564                            }
7565                        })
7566                        .collect();
7567                    terms.sort();
7568                    terms.dedup();
7569                    terms
7570                },
7571            )
7572        }
7573
7574        proptest! {
7575            #[test]
7576            fn prop_separator_keyed_section_answers_like_verbatim(
7577                terms in term_set(),
7578                extra in prop::collection::vec(any::<String>(), 0..8),
7579            ) {
7580                let raw = chunked_section_of(&terms);
7581                let sep = encode_chunked_dict_section(&raw, CODEC_NONE);
7582                let ver = verbatim_keyed_twin(&sep, CODEC_NONE);
7583                prop_assert!(sep.len() <= ver.len());
7584
7585                // The separator invariant, against the real chunk contents.
7586                let (_m, es) = parse_chunked_dict_dir(&sep, sep.len() as u64).unwrap();
7587                let bounds = chunk_bounds_of(&sep, CODEC_NONE);
7588                prop_assert!(es[0].key.is_empty());
7589                for i in 1..es.len() {
7590                    prop_assert!(es[i].key.as_slice() > bounds[i - 1].1.as_slice());
7591                    prop_assert!(es[i].key <= bounds[i].0);
7592                    prop_assert!(es[i].key > es[i - 1].key);
7593                }
7594
7595                let sec_s = decode_chunked_dict_section(
7596                    &sep,
7597                    CODEC_NONE,
7598                    crate::chunk_cache::ChunkCache::unlimited_arc(),
7599                    0,
7600                )
7601                .unwrap();
7602                let sec_v = decode_chunked_dict_section(
7603                    &ver,
7604                    CODEC_NONE,
7605                    crate::chunk_cache::ChunkCache::unlimited_arc(),
7606                    0,
7607                )
7608                .unwrap();
7609
7610                for (i, t) in terms.iter().enumerate() {
7611                    prop_assert_eq!(sec_s.id(t), Some(i as u32 + 1));
7612                    prop_assert_eq!(sec_v.id(t), Some(i as u32 + 1));
7613                    prop_assert_eq!(sec_s.term(i as u32 + 1), sec_v.term(i as u32 + 1));
7614                }
7615
7616                // Probes: arbitrary strings, plus mutations of every boundary.
7617                let mut probes: Vec<String> = extra;
7618                probes.push(String::new());
7619                for (first, last) in &bounds {
7620                    for base in [first, last] {
7621                        if let Ok(s) = std::str::from_utf8(base) {
7622                            probes.push(s.to_string());
7623                            probes.push(format!("{s}\u{1}"));
7624                            probes.push(s[..s.len() - s.chars().next_back()
7625                                .map(char::len_utf8).unwrap_or(0)].to_string());
7626                        }
7627                    }
7628                }
7629                for e in &es {
7630                    if let Ok(s) = std::str::from_utf8(&e.key) {
7631                        probes.push(s.to_string());
7632                    }
7633                }
7634                for p in &probes {
7635                    prop_assert_eq!(sec_s.id(p), sec_v.id(p), "probe diverged: {:?}", p);
7636                }
7637            }
7638        }
7639    }
7640
7641    /// The TEXT_INDEX section is inside the content hash: a freshly built
7642    /// text-indexed file must pass `verify()`, and flipping a byte inside the
7643    /// section must break it. (Regression: `verify()` once rebuilt the hash
7644    /// without the text index, so every `--text-index` file failed as corrupt.)
7645    #[test]
7646    fn text_index_is_tamper_evident_and_verifies() {
7647        let triples: Vec<(String, String, String)> = vec![(
7648            "<http://ex/s0>".to_string(),
7649            "<http://ex/label>".to_string(),
7650            "\"alpha glucose phosphate\"".to_string(),
7651        )];
7652        let bytes = build_text_indexed(&triples);
7653        let header = Rete::open(&bytes).unwrap().header().clone();
7654        assert!(header.text_index_len > 0);
7655        assert!(verify(&bytes).unwrap(), "a text-indexed build must verify");
7656
7657        let mut tampered = bytes.clone();
7658        tampered[header.text_index_offset as usize] ^= 0xff;
7659        assert!(
7660            !verify(&tampered).unwrap(),
7661            "tampering with the text index must break verify()"
7662        );
7663    }
7664
7665    /// End-to-end win: on a remote (range-read) file, a lookup whose routed tile
7666    /// is ruled out by a bound secondary fetches **fewer bytes** with the synopsis
7667    /// than without it — and the answer is identical (empty) either way.
7668    #[test]
7669    fn synopsis_cuts_remote_fetch_bytes() {
7670        use crate::header::FLAG_TILE_SYNOPSIS;
7671        use crate::reader::{CountingReader, SliceReader};
7672
7673        // Zero-padded terms ⇒ dictionary ids are monotonic in i; subject s_i pairs
7674        // only with object o_i, so an OSP tile (routed by object) holds a
7675        // contiguous subject range — a subject from a far tile is provably absent.
7676        let triples: Vec<(String, String, String)> = (0..400u32)
7677            .map(|i| {
7678                (
7679                    format!("<http://ex/s/{i:04}>"),
7680                    "<http://ex/p>".to_string(),
7681                    format!("<http://ex/o/{i:04}>"),
7682                )
7683            })
7684            .collect();
7685        let mut db = DictionaryBuilder::new();
7686        for (s, p, o) in &triples {
7687            db.observe(s, p, o);
7688        }
7689        let dict = db.build();
7690        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
7691        for (s, p, o) in &triples {
7692            ib.push(dict.encode(s, p, o).unwrap());
7693        }
7694        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
7695
7696        // (s_0395, ?, o_0005): routes OSP by the (early) object, secondary = the
7697        // (late) subject — outside that tile's subject range, so the synopsis
7698        // prunes the one routed tile.
7699        let q = (Some("<http://ex/s/0395>"), None, Some("<http://ex/o/0005>"));
7700        // Measure the bytes the QUERY pulls (after open) — isolating the per-query
7701        // saving from the one-time synopsis trailer reads done at open, which a
7702        // persistent remote session amortizes over many queries.
7703        let query_bytes = |image: &[u8]| -> (u64, usize) {
7704            let leaked: &'static [u8] = Box::leak(image.to_vec().into_boxed_slice());
7705            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
7706            let rete = Rete::open_ranged_lazy(reader.clone()).unwrap();
7707            let before = reader.bytes_read(); // after open (incl. trailer reads)
7708            let n = rete.query(q.0, q.1, q.2).len();
7709            assert!(!rete.index_incomplete());
7710            (reader.bytes_read() - before, n)
7711        };
7712
7713        let (on_bytes, on_n) = query_bytes(&bytes);
7714        // Same file with the synopsis flag cleared = an older reader's behavior.
7715        let mut off = bytes.clone();
7716        off[5] &= !FLAG_TILE_SYNOPSIS;
7717        let (off_bytes, off_n) = query_bytes(&off);
7718
7719        assert_eq!(on_n, 0, "the pair never co-occurs");
7720        assert_eq!(off_n, 0, "same answer without the synopsis");
7721        // Both pay the same dictionary-resolution bytes; the difference is the one
7722        // routed index tile that the synopsis skips (and the no-synopsis path
7723        // fetches only to have its zone map reject it).
7724        assert!(
7725            on_bytes < off_bytes,
7726            "synopsis skips the routed tile fetch: {on_bytes} < {off_bytes}"
7727        );
7728    }
7729
7730    /// A double-bound-object intersection (`?p P o1 ; P o2 ; label ?l`) — the
7731    /// shape whose REMOTE join strategy changed (scan + hash-join instead of
7732    /// probing each prefix row) — must return the SAME rows opened eagerly (in
7733    /// memory) and lazily (remote-style, `is_remote()` true). Strategy is a
7734    /// performance choice; the result multiset is invariant.
7735    #[test]
7736    fn double_bound_object_join_eager_matches_lazy() {
7737        use crate::reader::SliceReader;
7738        let occ = "<http://ex/occ>";
7739        let phys = "<http://ex/physicist>";
7740        let phil = "<http://ex/philosopher>";
7741        let label = "<http://www.w3.org/2000/01/rdf-schema#label>";
7742        // p00..p19 are physicists; p00..p09 are also philosophers (the answer).
7743        let mut triples: Vec<(String, String, String)> = Vec::new();
7744        for i in 0..20u32 {
7745            triples.push((format!("<http://ex/p/{i:02}>"), occ.into(), phys.into()));
7746            if i < 10 {
7747                triples.push((format!("<http://ex/p/{i:02}>"), occ.into(), phil.into()));
7748            }
7749            triples.push((
7750                format!("<http://ex/p/{i:02}>"),
7751                label.into(),
7752                format!("\"Name {i:02}\""),
7753            ));
7754        }
7755        let mut db = DictionaryBuilder::new();
7756        for (s, p, o) in &triples {
7757            db.observe(s, p, o);
7758        }
7759        let dict = db.build();
7760        let mut ib = GraphIndexBuilder::new().with_tile_budget(16);
7761        for (s, p, o) in &triples {
7762            ib.push(dict.encode(s, p, o).unwrap());
7763        }
7764        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
7765
7766        let q = "SELECT ?l WHERE { \
7767            ?p <http://ex/occ> <http://ex/physicist> ; \
7768               <http://ex/occ> <http://ex/philosopher> ; \
7769               <http://www.w3.org/2000/01/rdf-schema#label> ?l }";
7770        let run = |rete: &Rete| -> Vec<String> {
7771            let (_, sols) = crate::eval_sparql(rete, q).unwrap();
7772            let mut v: Vec<String> = sols.iter().filter_map(|b| b.get("l").cloned()).collect();
7773            v.sort();
7774            v
7775        };
7776
7777        let eager_rows = run(&Rete::open(&bytes).unwrap());
7778        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
7779        let lazy = Rete::open_ranged_lazy(std::sync::Arc::new(SliceReader::new(leaked))).unwrap();
7780        let lazy_rows = run(&lazy);
7781        assert!(!lazy.index_incomplete());
7782
7783        assert_eq!(eager_rows.len(), 10, "the 10 physicist∩philosopher labels");
7784        assert_eq!(eager_rows, lazy_rows, "eager and lazy must agree exactly");
7785    }
7786
7787    /// A dictionary big enough to split into multiple chunks per section must
7788    /// round-trip every id↔term mapping through the chunked (v0.2) encoding —
7789    /// including terms at chunk boundaries and absent near-misses.
7790    #[test]
7791    fn multi_chunk_dictionary_round_trips() {
7792        let mut db = DictionaryBuilder::new();
7793        let term = |i: u32| format!("<http://example.org/some/long/prefix/entity/{i:06}>");
7794        for i in 0..6000u32 {
7795            db.observe(&term(i), "<http://ex/p>", &term(i + 1));
7796        }
7797        let dict = db.build();
7798        let mut ib = GraphIndexBuilder::new();
7799        for i in 0..6000u32 {
7800            ib.push(
7801                dict.encode(&term(i), "<http://ex/p>", &term(i + 1))
7802                    .unwrap(),
7803            );
7804        }
7805        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
7806        let rete = Rete::open(&bytes).unwrap();
7807        let d = rete.dictionary();
7808        assert_eq!(d.term_count(), dict.term_count());
7809        for i in (0..6000).step_by(97).chain([0, 1, 5999, 6000]) {
7810            let t = term(i);
7811            let sid = dict.subject_id(&t);
7812            assert_eq!(d.subject_id(&t), sid, "subject_id({t})");
7813            if let Some(id) = sid {
7814                assert_eq!(d.subject_term(id).as_deref(), Some(t.as_str()));
7815            }
7816            let oid = dict.object_id(&t);
7817            assert_eq!(d.object_id(&t), oid, "object_id({t})");
7818        }
7819        assert_eq!(d.subject_id("<http://example.org/absent>"), None);
7820        assert_eq!(d.predicate_id("<http://ex/p>"), Some(1));
7821        assert_eq!(d.predicate_term(1).as_deref(), Some("<http://ex/p>"));
7822    }
7823
7824    #[test]
7825    #[cfg(feature = "compression")]
7826    fn compression_shrinks_repetitive_data() {
7827        // Many triples sharing IRI prefixes — exactly what front-coding + zstd
7828        // should crush. The compressed file must be far smaller than the raw
7829        // term bytes, and still query correctly.
7830        let mut db = DictionaryBuilder::new();
7831        let triples: Vec<(String, String, String)> = (0..500)
7832            .map(|i| {
7833                (
7834                    format!("<http://example.org/entity/{i}>"),
7835                    "<http://example.org/p/relatedTo>".to_string(),
7836                    format!("<http://example.org/entity/{}>", (i + 1) % 500),
7837                )
7838            })
7839            .collect();
7840        for (s, p, o) in &triples {
7841            db.observe(s, p, o);
7842        }
7843        let dict = db.build();
7844        let mut ib = GraphIndexBuilder::new();
7845        for (s, p, o) in &triples {
7846            ib.push(dict.encode(s, p, o).unwrap());
7847        }
7848        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
7849
7850        let raw: usize = triples
7851            .iter()
7852            .map(|(s, p, o)| s.len() + p.len() + o.len())
7853            .sum();
7854        assert!(
7855            bytes.len() < raw / 2,
7856            "expected strong compression: file {} vs raw terms {raw}",
7857            bytes.len()
7858        );
7859
7860        // Still queryable after compression.
7861        let rete = Rete::open(&bytes).unwrap();
7862        let r = rete.query(Some("<http://example.org/entity/0>"), None, None);
7863        assert_eq!(r.len(), 1);
7864        assert_eq!(r[0].2, "<http://example.org/entity/1>");
7865    }
7866
7867    fn big_file_with_pyramid() -> Vec<u8> {
7868        // A ring of 300 entities -> index dwarfs dict+meta.
7869        let triples: Vec<(String, String, String)> = (0..300)
7870            .map(|i| {
7871                (
7872                    format!("<http://ex/e{i}>"),
7873                    "<http://ex/next>".to_string(),
7874                    format!("<http://ex/e{}>", (i + 1) % 300),
7875                )
7876            })
7877            .collect();
7878        let mut db = DictionaryBuilder::new();
7879        for (s, p, o) in &triples {
7880            db.observe(s, p, o);
7881        }
7882        let dict = db.build();
7883        let ids: Vec<_> = triples
7884            .iter()
7885            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
7886            .collect();
7887        let mut ib = GraphIndexBuilder::new();
7888        for &t in &ids {
7889            ib.push(t);
7890        }
7891        let (meta, levels) = build_pyramid_meta(&dict, &ids, DEFAULT_TILE_BUDGET);
7892        write_file(&dict, &ib.build(), false, &meta, levels)
7893    }
7894
7895    #[test]
7896    fn ranged_open_is_minimal_and_correct() {
7897        use crate::reader::{CountingReader, SliceReader};
7898        let bytes = big_file_with_pyramid();
7899
7900        let full = CountingReader::new(SliceReader::new(&bytes));
7901        let rete = Rete::open_ranged(&full).unwrap();
7902        // Full open touches at most 4 ranges (header, dict, index, meta).
7903        assert!(full.requests() <= 4, "requests = {}", full.requests());
7904        assert_eq!(
7905            rete.query(Some("<http://ex/e0>"), None, None)[0].2,
7906            "<http://ex/e1>"
7907        );
7908
7909        // Summary-only open skips the index → strictly fewer bytes than the file.
7910        let summ_reader = CountingReader::new(SliceReader::new(&bytes));
7911        let view = SummaryView::open_ranged(&summ_reader).unwrap().unwrap();
7912        assert!(!view.summary.is_empty());
7913        assert!(
7914            summ_reader.bytes_read() < bytes.len() as u64,
7915            "summary read {} of {} bytes",
7916            summ_reader.bytes_read(),
7917            bytes.len()
7918        );
7919        // And fewer than a full open, since it never fetched the index.
7920        assert!(summ_reader.bytes_read() < full.bytes_read());
7921    }
7922
7923    #[test]
7924    fn content_hash_is_set_and_verifies() {
7925        let bytes = build_image();
7926        let rete = Rete::open(&bytes).unwrap();
7927        assert_ne!(
7928            rete.header().content_hash,
7929            [0u8; 16],
7930            "hash must be populated"
7931        );
7932        assert!(verify(&bytes).unwrap(), "freshly built file verifies");
7933        assert!(
7934            verify_ranged(&crate::reader::SliceReader::new(&bytes)).unwrap(),
7935            "the streaming verify agrees"
7936        );
7937
7938        // Same data builds an identical hash (deterministic).
7939        assert_eq!(
7940            Rete::open(&build_image()).unwrap().header().content_hash,
7941            rete.header().content_hash
7942        );
7943
7944        // Corrupting a payload byte breaks verification.
7945        let mut tampered = bytes.clone();
7946        let last = tampered.len() - 5; // inside payload, before footer magic
7947        tampered[last] ^= 0xff;
7948        assert!(!verify(&tampered).unwrap());
7949        assert!(!verify_ranged(&crate::reader::SliceReader::new(&tampered)).unwrap());
7950        // A truncated file is an error, never a pass.
7951        let short = &bytes[..bytes.len() / 2];
7952        assert!(verify_ranged(&crate::reader::SliceReader::new(short)).is_err());
7953        assert!(verify_ranged(&crate::reader::SliceReader::new(&bytes[..10])).is_err());
7954    }
7955
7956    /// Build the standard 3-triple image with an opaque metadata payload.
7957    fn build_with_metadata(meta: &[u8]) -> Vec<u8> {
7958        let triples = [
7959            ("Alice", "knows", "Bob"),
7960            ("Bob", "knows", "Carol"),
7961            ("Alice", "age", "30"),
7962        ];
7963        let mut db = DictionaryBuilder::new();
7964        for (s, p, o) in triples {
7965            db.observe(s, p, o);
7966        }
7967        let dict = db.build();
7968        let ids: Vec<_> = triples
7969            .iter()
7970            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
7971            .collect();
7972        let mut ib = GraphIndexBuilder::new();
7973        for &t in &ids {
7974            ib.push(t);
7975        }
7976        let (pmeta, levels) = build_pyramid_meta(&dict, &ids, DEFAULT_TILE_BUDGET);
7977        write_dataset_with_metadata(&dict, &ib.build(), &[], false, &pmeta, levels, meta, &[])
7978    }
7979
7980    #[test]
7981    fn metadata_round_trips_and_shifts_offsets() {
7982        let card = br#"{"title":"My Dataset"}"#;
7983        let bytes = build_with_metadata(card);
7984        let rete = Rete::open(&bytes).unwrap();
7985
7986        // The opaque payload reads back verbatim.
7987        assert_eq!(rete.metadata(), Some(card.as_slice()));
7988        let h = rete.header();
7989        assert_eq!(h.metadata_offset, HEADER_LEN as u64);
7990        assert_eq!(h.metadata_len, card.len() as u64);
7991        // The dictionary (and everything after it) shifted forward by the card.
7992        assert_eq!(h.dictionary_offset, HEADER_LEN as u64 + card.len() as u64);
7993
7994        // The index still decodes correctly at its shifted offset.
7995        assert_eq!(
7996            rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
7997            1
7998        );
7999        // The card is inside the content hash, so the file still verifies.
8000        assert!(verify(&bytes).unwrap());
8001    }
8002
8003    #[test]
8004    fn empty_metadata_is_byte_identical_to_plain_writer() {
8005        // The `&[]` path must produce exactly the bytes of the metadata-free
8006        // writer for identical inputs — old files and outputs are unchanged.
8007        assert_eq!(
8008            build_with_metadata(&[]),
8009            build_image(),
8010            "empty-metadata output must equal the plain writer byte-for-byte"
8011        );
8012    }
8013
8014    /// The contract `replace_metadata` has to meet to be usable at the end of a
8015    /// build: swapping the card must land on **exactly** the bytes the writer
8016    /// would have produced with that card in the first place — shorter, longer,
8017    /// and gone — so a spliced file and a rebuilt one are the same file.
8018    #[test]
8019    fn replace_metadata_matches_a_direct_write() {
8020        let first = br#"{"title":"My Dataset","queries":[1,2,3]}"#;
8021        let bytes = build_with_metadata(first);
8022        for target in [
8023            br#"{"title":"My Dataset","queries":[1,2]}"#.as_slice(), // shorter
8024            br#"{"title":"My Dataset","queries":[1,2,3,4,5,6,7]}"#.as_slice(), // longer
8025            b"".as_slice(),                                          // removed
8026            first.as_slice(),                                        // unchanged
8027        ] {
8028            let spliced = replace_metadata(&bytes, target).unwrap();
8029            assert_eq!(
8030                spliced,
8031                build_with_metadata(target),
8032                "splicing {} bytes of metadata must equal a direct write",
8033                target.len()
8034            );
8035            assert!(
8036                verify(&spliced).unwrap(),
8037                "the new hash covers the new card"
8038            );
8039            let rete = Rete::open(&spliced).unwrap();
8040            assert_eq!(
8041                rete.metadata(),
8042                (!target.is_empty()).then_some(target),
8043                "the new payload reads back verbatim"
8044            );
8045            // The data is untouched by the surgery.
8046            assert_eq!(
8047                rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
8048                1
8049            );
8050        }
8051    }
8052
8053    /// A build-info section survives the splice, still adjacent to the card, and
8054    /// still outside the content hash.
8055    #[test]
8056    fn replace_metadata_keeps_build_info_adjacent() {
8057        let with_info =
8058            attach_build_info(&build_with_metadata(br#"{"a":1}"#), br#"{"builder":"x"}"#).unwrap();
8059        let spliced = replace_metadata(&with_info, br#"{"a":1,"b":2}"#).unwrap();
8060        let h = Header::from_bytes(&spliced).unwrap();
8061        assert_eq!(h.metadata_offset, HEADER_LEN as u64);
8062        assert_eq!(h.build_info_offset, HEADER_LEN as u64 + h.metadata_len);
8063        assert_eq!(
8064            read_build_info(&spliced).unwrap().as_deref(),
8065            Some(br#"{"builder":"x"}"#.as_slice())
8066        );
8067        assert!(verify(&spliced).unwrap());
8068        // Same card, same content hash, whatever the build-info says.
8069        assert_eq!(
8070            h.content_hash,
8071            Header::from_bytes(&build_with_metadata(br#"{"a":1,"b":2}"#))
8072                .unwrap()
8073                .content_hash
8074        );
8075    }
8076
8077    #[test]
8078    fn metadata_is_tamper_evident() {
8079        let card = br#"{"title":"x"}"#;
8080        let mut bytes = build_with_metadata(card);
8081        assert!(verify(&bytes).unwrap());
8082        // The card occupies [HEADER_LEN .. HEADER_LEN+card_len); flip a byte in it.
8083        bytes[HEADER_LEN + 2] ^= 0xff;
8084        assert!(
8085            !verify(&bytes).unwrap(),
8086            "tampering with the card must break verify()"
8087        );
8088    }
8089
8090    #[test]
8091    fn ranged_opens_do_not_fetch_metadata() {
8092        use crate::reader::{CountingReader, SliceReader};
8093        let card = vec![0xABu8; 512]; // distinctive and sizable
8094        let bytes = build_with_metadata(&card);
8095        let total = bytes.len() as u64;
8096
8097        // A full ranged open never loads the card and never reads its byte range.
8098        let r = CountingReader::new(SliceReader::new(&bytes));
8099        let rete = Rete::open_ranged(&r).unwrap();
8100        assert!(
8101            rete.metadata().is_none(),
8102            "open_ranged must not load the card"
8103        );
8104        assert!(r.requests() <= 4, "requests = {}", r.requests());
8105        assert!(
8106            r.bytes_read() <= total - card.len() as u64,
8107            "read {} of {} bytes; the {}-byte card must be skipped",
8108            r.bytes_read(),
8109            total,
8110            card.len()
8111        );
8112
8113        // Summary-only open likewise ignores the card and still summarizes.
8114        let rs = CountingReader::new(SliceReader::new(&bytes));
8115        let view = SummaryView::open_ranged(&rs).unwrap().unwrap();
8116        assert!(!view.summary.is_empty());
8117        assert!(rs.bytes_read() <= total - card.len() as u64);
8118    }
8119
8120    #[test]
8121    fn metadata_ranged_fetches_only_header_and_card() {
8122        use crate::reader::{CountingReader, SliceReader};
8123        // The CARD tier: fetch the self-description over a RangeReader touching
8124        // only the header + metadata range — never the dictionary/index/pyramid.
8125        let card = vec![0xCDu8; 384];
8126        let bytes = build_with_metadata(&card);
8127
8128        let r = CountingReader::new(SliceReader::new(&bytes));
8129        let got = read_metadata_ranged(&r).unwrap().unwrap();
8130        assert_eq!(got, card, "the card reads back verbatim");
8131        assert_eq!(r.requests(), 2, "exactly header + metadata ranges");
8132        assert_eq!(
8133            r.bytes_read(),
8134            HEADER_LEN as u64 + card.len() as u64,
8135            "no dictionary/index/pyramid bytes are touched"
8136        );
8137
8138        // A cardless file resolves to None after a single header read.
8139        let plain = build_image();
8140        let rp = CountingReader::new(SliceReader::new(&plain));
8141        assert!(read_metadata_ranged(&rp).unwrap().is_none());
8142        assert_eq!(rp.requests(), 1, "header only for a cardless file");
8143        assert_eq!(rp.bytes_read(), HEADER_LEN as u64);
8144    }
8145
8146    #[test]
8147    fn build_info_attaches_outside_the_hash() {
8148        let card = br#"{"title":"My Dataset"}"#;
8149        let base = build_with_metadata(card);
8150        let base_hash = Header::from_bytes(&base).unwrap().content_hash;
8151
8152        let info = br#"{"built_at":"2026-08-04T00:00:00Z","builder":"rete-cli 0.3.2"}"#;
8153        let with = attach_build_info(&base, info).unwrap();
8154
8155        // The section reads back verbatim, adjacent to the metadata.
8156        assert_eq!(read_build_info(&with).unwrap().unwrap(), info);
8157        let h = Header::from_bytes(&with).unwrap();
8158        assert_eq!(h.build_info_offset, HEADER_LEN as u64 + card.len() as u64);
8159        assert_eq!(h.build_info_len, info.len() as u64);
8160        assert_eq!(h.expected_file_len(), Some(with.len() as u64));
8161
8162        // OUTSIDE the hash: the content hash is unchanged and the file still
8163        // verifies — two builds of identical data stay hash-equal even though
8164        // their build-info differs.
8165        assert_eq!(h.content_hash, base_hash);
8166        assert!(verify(&with).unwrap());
8167        let other = attach_build_info(&base, br#"{"built_at":"1999-01-01T00:00:00Z"}"#).unwrap();
8168        assert_eq!(Header::from_bytes(&other).unwrap().content_hash, base_hash);
8169
8170        // The graph still opens and answers at its shifted offsets.
8171        let rete = Rete::open(&with).unwrap();
8172        assert_eq!(
8173            rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
8174            1
8175        );
8176        assert_eq!(rete.metadata(), Some(card.as_slice()));
8177
8178        // Replacing an existing section works and removing it restores the
8179        // original image byte-for-byte (the strip-equality determinism proof).
8180        let replaced = attach_build_info(&with, br#"{"builder":"other"}"#).unwrap();
8181        assert_eq!(
8182            read_build_info(&replaced).unwrap().unwrap(),
8183            br#"{"builder":"other"}"#
8184        );
8185        assert!(verify(&replaced).unwrap());
8186        let stripped = attach_build_info(&replaced, &[]).unwrap();
8187        assert_eq!(stripped, base, "stripping build-info restores the image");
8188    }
8189
8190    #[test]
8191    fn build_info_attaches_on_a_metadata_free_file() {
8192        // No card: the section slots in right after the header.
8193        let base = build_image();
8194        let info = b"{\"builder\":\"x\"}";
8195        let with = attach_build_info(&base, info).unwrap();
8196        let h = Header::from_bytes(&with).unwrap();
8197        assert_eq!(h.build_info_offset, HEADER_LEN as u64);
8198        assert_eq!(h.metadata_len, 0);
8199        assert_eq!(read_build_info(&with).unwrap().unwrap(), info);
8200        assert!(verify(&with).unwrap());
8201        assert_eq!(
8202            h.content_hash,
8203            Header::from_bytes(&base).unwrap().content_hash
8204        );
8205        Rete::open(&with).unwrap();
8206    }
8207
8208    /// The contract a **streaming** rewriter needs: a plan is enough to rebuild
8209    /// the file from three spans — the header it hands back, the untouched
8210    /// `[HEADER_LEN, insert)` prefix, the new section, and the untouched
8211    /// `[tail_start, len)` tail — and the result is byte-identical to the
8212    /// in-memory splice. That is what lets a 17 GB file gain a build record
8213    /// with a 4 MiB buffer instead of 34 GB of RAM.
8214    #[test]
8215    fn a_plan_rebuilds_exactly_what_the_in_memory_splice_produces() {
8216        let card = br#"{"title":"My Dataset"}"#;
8217        let base = build_with_metadata(card);
8218        let base_hash = Header::from_bytes(&base).unwrap().content_hash;
8219        for start in [
8220            base.clone(),
8221            attach_build_info(&base, b"{\"a\":1}").unwrap(),
8222        ] {
8223            for info in [
8224                br#"{"builder":"rete-cli 0.3.2","query_costs":{}}"#.as_slice(), // longer
8225                b"{}".as_slice(),                                               // shorter
8226                b"".as_slice(),                                                 // removed
8227            ] {
8228                let plan = plan_build_info(&start, start.len() as u64, info.len() as u64).unwrap();
8229                let mut streamed = Vec::new();
8230                streamed.extend_from_slice(&plan.header);
8231                streamed.extend_from_slice(&start[HEADER_LEN..plan.insert as usize]);
8232                streamed.extend_from_slice(info);
8233                streamed.extend_from_slice(&start[plan.tail_start as usize..]);
8234                assert_eq!(streamed.len() as u64, plan.new_len);
8235                assert_eq!(
8236                    streamed,
8237                    attach_build_info(&start, info).unwrap(),
8238                    "a {}-byte section streamed must equal the same section spliced",
8239                    info.len()
8240                );
8241                // The point of the exercise: the file's identity is untouched.
8242                assert!(verify(&streamed).unwrap());
8243                let h = Header::from_bytes(&streamed).unwrap();
8244                assert_eq!(h.content_hash, base_hash);
8245                assert_eq!(h.expected_file_len(), Some(plan.new_len));
8246                let rete = Rete::open(&streamed).unwrap();
8247                assert_eq!(rete.metadata(), Some(card.as_slice()));
8248                assert_eq!(
8249                    rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
8250                    1
8251                );
8252            }
8253        }
8254    }
8255
8256    /// A plan is refused rather than trusted when the header's own numbers put
8257    /// the section outside the file — the header is attacker-controlled input.
8258    #[test]
8259    fn a_plan_refuses_a_header_that_overruns_the_file() {
8260        let base = build_with_metadata(br#"{"title":"x"}"#);
8261        assert!(plan_build_info(&base, 8, 16).is_err());
8262        assert!(plan_build_info(&base[..HEADER_LEN], base.len() as u64, 16).is_ok());
8263    }
8264
8265    #[test]
8266    fn card_and_build_info_ranged_is_one_header_plus_one_range() {
8267        use crate::reader::{CountingReader, SliceReader};
8268        let card = vec![0xCDu8; 384];
8269        let info = vec![0xEFu8; 200];
8270        let bytes = attach_build_info(&build_with_metadata(&card), &info).unwrap();
8271
8272        // Both sections in ONE coalesced range after the header — the CARD
8273        // tier's 1 header + 1 range budget holds with build info included.
8274        let r = CountingReader::new(SliceReader::new(&bytes));
8275        let (m, b) = read_card_and_build_info_ranged(&r).unwrap();
8276        assert_eq!(m.unwrap(), card);
8277        assert_eq!(b.unwrap(), info);
8278        assert_eq!(r.requests(), 2, "header + one coalesced range");
8279        assert_eq!(
8280            r.bytes_read(),
8281            HEADER_LEN as u64 + card.len() as u64 + info.len() as u64
8282        );
8283
8284        // Card only: same two requests, no build info.
8285        let plain = build_with_metadata(&card);
8286        let rp = CountingReader::new(SliceReader::new(&plain));
8287        let (m, b) = read_card_and_build_info_ranged(&rp).unwrap();
8288        assert_eq!(m.unwrap(), card);
8289        assert!(b.is_none());
8290        assert_eq!(rp.requests(), 2);
8291        // Stated as the comparison every client depends on: carrying a build
8292        // record costs a reader NOTHING in requests. Any client that shows the
8293        // build record — the CLI, the browser card modal — can therefore do it
8294        // in the card's own budget, and does not need a second call.
8295        assert_eq!(
8296            r.requests(),
8297            rp.requests(),
8298            "build info must not cost an extra request",
8299        );
8300
8301        // Neither: the header read alone answers.
8302        let none = build_image();
8303        let rn = CountingReader::new(SliceReader::new(&none));
8304        let (m, b) = read_card_and_build_info_ranged(&rn).unwrap();
8305        assert!(m.is_none() && b.is_none());
8306        assert_eq!(rn.requests(), 1);
8307    }
8308
8309    #[test]
8310    fn schema_summary_groups_by_type() {
8311        let rt = RDF_TYPE;
8312        let bytes = build_from(&[
8313            ("Alice", rt, "Person"),
8314            ("Bob", rt, "Person"),
8315            ("NYC", rt, "City"),
8316            ("Alice", "knows", "Bob"),
8317            ("Alice", "livesIn", "NYC"),
8318            ("Alice", "name", "\"Alice\""),
8319        ]);
8320        let rete = Rete::open(&bytes).unwrap();
8321        let summary = schema_summary(&rete);
8322        // Expect class-level relations, rdf:type excluded.
8323        assert!(summary.contains(&("Person".into(), "knows".into(), "Person".into(), 1)));
8324        assert!(summary.contains(&("Person".into(), "livesIn".into(), "City".into(), 1)));
8325        assert!(summary.contains(&("Person".into(), "name".into(), "(literal)".into(), 1)));
8326        // No rdf:type relations in the summary.
8327        assert!(!summary.iter().any(|(_, p, _, _)| p == RDF_TYPE));
8328
8329        // Class populations: 2 People, 1 City, sorted by count desc.
8330        let classes = schema_classes(&rete);
8331        assert_eq!(
8332            classes,
8333            vec![("Person".into(), 2u32), ("City".into(), 1u32)]
8334        );
8335    }
8336
8337    fn build_from(triples: &[(&str, &str, &str)]) -> Vec<u8> {
8338        let mut db = DictionaryBuilder::new();
8339        for (s, p, o) in triples {
8340            db.observe(s, p, o);
8341        }
8342        let dict = db.build();
8343        let mut ib = GraphIndexBuilder::new();
8344        for (s, p, o) in triples {
8345            ib.push(dict.encode(s, p, o).unwrap());
8346        }
8347        write_file(&dict, &ib.build(), false, &[], 0)
8348    }
8349
8350    /// Build a file WITH a pyramid (so the schema pyramid + coherence axioms ship).
8351    fn build_with_pyramid(triples: &[(&str, &str, &str)]) -> Vec<u8> {
8352        let mut db = DictionaryBuilder::new();
8353        for (s, p, o) in triples {
8354            db.observe(s, p, o);
8355        }
8356        let dict = db.build();
8357        let encoded: Vec<_> = triples
8358            .iter()
8359            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
8360            .collect();
8361        let mut ib = GraphIndexBuilder::new();
8362        for t in &encoded {
8363            ib.push(*t);
8364        }
8365        let (meta, levels) = build_pyramid_meta(&dict, &encoded, DEFAULT_TILE_BUDGET);
8366        write_dataset(&dict, &ib.build(), &[], false, &meta, levels)
8367    }
8368
8369    #[test]
8370    fn tbox_coherence_flags_unsatisfiable_class_index_free() {
8371        use crate::reader::{CountingReader, SliceReader};
8372        let rt = RDF_TYPE;
8373        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
8374        let disj = "<http://www.w3.org/2002/07/owl#disjointWith>";
8375        // C ⊑ D, C ⊑ E, D disjointWith E ⇒ C unsatisfiable — schema-only, with one
8376        // instance present just so the schema pyramid gets built.
8377        let bytes = build_with_pyramid(&[
8378            ("<http://ex/C>", sub, "<http://ex/D>"),
8379            ("<http://ex/C>", sub, "<http://ex/E>"),
8380            ("<http://ex/D>", disj, "<http://ex/E>"),
8381            ("<http://ex/x>", rt, "<http://ex/C>"),
8382        ]);
8383
8384        let r = CountingReader::new(SliceReader::new(&bytes));
8385        let view = SummaryView::open_ranged(&r).unwrap().unwrap();
8386        let points = view.tbox_coherence();
8387        assert!(
8388            points
8389                .iter()
8390                .any(|i| i.kind == "unsatisfiable-class" && i.detail.contains("http://ex/C>")),
8391            "expected C unsatisfiable from the schema alone, got {points:?}"
8392        );
8393
8394        // Proven index-free: bytes read never reach the (root_dir) index section,
8395        // mirroring `schema_pyramid_round_trips_through_file_index_free`.
8396        let header = Header::from_bytes(&bytes[..HEADER_LEN]).unwrap();
8397        assert!(
8398            r.bytes_read() <= bytes.len() as u64 - header.root_dir_len,
8399            "tbox_coherence must not read the triple index"
8400        );
8401    }
8402
8403    #[test]
8404    fn schema_coherence_reads_only_the_schema_block() {
8405        use crate::reader::{CountingReader, SliceReader};
8406        let rt = RDF_TYPE;
8407        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
8408        let disj = "<http://www.w3.org/2002/07/owl#disjointWith>";
8409        // 500 instances with unique literals → a sizable dictionary + community
8410        // summary, so a whole-pyramid-meta read would be large; the schema block
8411        // (bounded by the tiny ontology) stays small.
8412        let mut triples: Vec<(String, String, String)> = vec![
8413            ("<http://ex/C>".into(), sub.into(), "<http://ex/D>".into()),
8414            ("<http://ex/C>".into(), sub.into(), "<http://ex/E>".into()),
8415            ("<http://ex/D>".into(), disj.into(), "<http://ex/E>".into()),
8416        ];
8417        for i in 0..500 {
8418            let s = format!("<http://ex/x{i}>");
8419            triples.push((s.clone(), rt.into(), "<http://ex/C>".into()));
8420            triples.push((
8421                s,
8422                "<http://ex/label>".into(),
8423                format!("\"unique label {i}\""),
8424            ));
8425        }
8426        let trefs: Vec<(&str, &str, &str)> = triples
8427            .iter()
8428            .map(|(s, p, o)| (s.as_str(), p.as_str(), o.as_str()))
8429            .collect();
8430        let bytes = build_with_pyramid(&trefs);
8431
8432        let header = Header::from_bytes(&bytes[..HEADER_LEN]).unwrap();
8433        assert!(
8434            header.schema_meta_len > 0,
8435            "the writer recorded a schema-block length"
8436        );
8437        assert!(
8438            (header.schema_meta_len as u64) < header.pyramid_meta_len,
8439            "schema block ({}) should be far smaller than the whole pyramid-meta ({})",
8440            header.schema_meta_len,
8441            header.pyramid_meta_len
8442        );
8443
8444        let r = CountingReader::new(SliceReader::new(&bytes));
8445        let points = read_schema_coherence_ranged(&r).unwrap().unwrap();
8446        assert!(points.iter().any(|i| i.kind == "unsatisfiable-class"));
8447        // It read only the header + the schema block — not the summary or dictionary.
8448        assert!(
8449            r.bytes_read() <= HEADER_LEN as u64 + header.schema_meta_len as u64,
8450            "read {} bytes; expected <= header + schema block ({})",
8451            r.bytes_read(),
8452            HEADER_LEN as u64 + header.schema_meta_len as u64
8453        );
8454    }
8455
8456    #[test]
8457    fn tbox_coherence_clean_schema_is_coherent() {
8458        use crate::reader::SliceReader;
8459        let rt = RDF_TYPE;
8460        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
8461        let bytes = build_with_pyramid(&[
8462            ("<http://ex/Dog>", sub, "<http://ex/Animal>"),
8463            ("<http://ex/x>", rt, "<http://ex/Dog>"),
8464        ]);
8465        let view = SummaryView::open_ranged(&SliceReader::new(&bytes))
8466            .unwrap()
8467            .unwrap();
8468        assert!(view.tbox_is_coherent(), "a plain hierarchy is coherent");
8469    }
8470
8471    /// A quad-index file must be **refused** by every reader that predates it —
8472    /// an old reader ignores flag bit 3 and the unknown kind 10, so it would
8473    /// otherwise open the file as one without named graphs and answer `GRAPH`
8474    /// queries with nothing. The seal makes each of their open paths fail on the
8475    /// index container's section count, before any payload is read; their
8476    /// `verify` still passes, because the part list it hashes is unchanged.
8477    /// (Checked against real 0.3.x binaries too: QUAD-INDEX-DESIGN.md §9.)
8478    #[test]
8479    fn readers_before_the_quad_index_refuse_its_files() {
8480        for perms in [PermSet::ALL, PermSet::CORE] {
8481            let quads: Vec<crate::ingest::RawQuad> = (0..50)
8482                .map(|i| {
8483                    (
8484                        format!("<http://ex/s{i}>"),
8485                        "<http://ex/p>".to_string(),
8486                        format!("\"{i}\""),
8487                        Some(format!("<http://ex/g{}>", i % 7)),
8488                    )
8489                })
8490                .collect();
8491            let (bytes, _) = crate::ingest::assemble_dataset_with_layout(
8492                quads,
8493                false,
8494                false,
8495                None,
8496                crate::PyramidAlgo::Louvain,
8497                perms,
8498                crate::ingest::GraphLayout::Quad,
8499                |_, _| Vec::<u8>::new(),
8500            );
8501            let h = Header::from_bytes(&bytes).unwrap();
8502            assert!(h.has_quad_index());
8503            let root =
8504                &bytes[h.root_dir_offset as usize..(h.root_dir_offset + h.root_dir_len) as usize];
8505            // 0.3.4–0.3.7, resident (`Rete::open`, `open_ranged`): count != mask.
8506            assert!(decode_index_container(root, h.block_codec, h.perms).is_err());
8507            assert!(
8508                container_section_payload_ranges(root, h.root_dir_offset, h.perms.len()).is_err()
8509            );
8510            // 0.3.0–0.3.3 expected exactly six sections, whatever the mask.
8511            assert_ne!(decode_container(root, CODEC_NONE).unwrap().len(), NUM_PERMS);
8512            assert_ne!(
8513                decode_container(root, CODEC_NONE).unwrap().len(),
8514                h.perms.len()
8515            );
8516            // Every lazy / remote open (`open_ranged_lazy`, `query_ranged`).
8517            let r = crate::reader::SliceReader::new(&bytes);
8518            for expect in [h.perms.len(), NUM_PERMS] {
8519                assert!(locate_container_section_ranged(
8520                    &r,
8521                    h.root_dir_offset,
8522                    h.root_dir_len,
8523                    0,
8524                    expect as u64
8525                )
8526                .is_err());
8527            }
8528            // Their `verify` hashes the same parts the writer hashed.
8529            assert_eq!(hash_of_sections(&h, &bytes).unwrap(), h.content_hash);
8530            // And a current reader reads the file.
8531            let rete = Rete::open(&bytes).unwrap();
8532            assert_eq!(rete.named_graph_count(), 7);
8533            assert_eq!(rete.dump(Some("<http://ex/g3>")).len(), 7);
8534        }
8535    }
8536
8537    #[test]
8538    fn named_graphs_round_trip() {
8539        // One shared dictionary; default graph + a named graph "g1".
8540        let all = [
8541            ("Alice", "knows", "Bob"), // default
8542            ("Bob", "age", "30"),      // named g1
8543        ];
8544        let mut db = DictionaryBuilder::new();
8545        for (s, p, o) in all {
8546            db.observe(s, p, o);
8547        }
8548        let dict = db.build();
8549
8550        let mut def = GraphIndexBuilder::new();
8551        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
8552        let mut g1 = GraphIndexBuilder::new();
8553        g1.push(dict.encode("Bob", "age", "30").unwrap());
8554
8555        let named = vec![("http://ex/g1".to_string(), g1.build())];
8556        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
8557
8558        assert!(verify(&bytes).unwrap());
8559        let rete = Rete::open(&bytes).unwrap();
8560        assert_eq!(rete.graph_names(), vec!["http://ex/g1"]);
8561        // The named graph contains Bob age 30, not the default-graph triple.
8562        let gi = rete.graph_index("http://ex/g1").unwrap();
8563        assert_eq!(gi.triple_count(), 1);
8564        assert!(rete.graph_index("http://ex/missing").is_none());
8565
8566        // quad_count counts ALL quads — default graph + named graphs (1 + 1),
8567        // not just the default index (which would report 1).
8568        assert_eq!(rete.header().quad_count, 2);
8569
8570        // Default-graph query path is unchanged.
8571        assert_eq!(rete.query(Some("Alice"), None, None).len(), 1);
8572
8573        // dump() round-trips each graph back to terms.
8574        assert_eq!(
8575            rete.dump(None),
8576            vec![("Alice".into(), "knows".into(), "Bob".into())]
8577        );
8578        assert_eq!(
8579            rete.dump(Some("http://ex/g1")),
8580            vec![("Bob".into(), "age".into(), "30".into())]
8581        );
8582    }
8583
8584    /// A named graph whose container exceeds [`NAMED_GRAPH_RESIDENT_MAX`]
8585    /// opens through [`open_index_container_lazy`] — the default graph's own
8586    /// machinery, at the container's offset. Drive that helper directly over a
8587    /// named graph's container range (the threshold itself is too large to
8588    /// cross cheaply in a unit test) and require identical matches.
8589    #[test]
8590    fn named_graph_container_opens_tile_lazily_like_the_root() {
8591        let mut db = DictionaryBuilder::new();
8592        let node = |n: u32| format!("<http://ex/n{n}>");
8593        let p = "<http://ex/p>".to_string();
8594        db.observe(&node(0), &p, &node(1));
8595        for i in 0..500u32 {
8596            db.observe(&node(1000 + i), &p, &node(2000 + i));
8597        }
8598        let dict = db.build();
8599        let mut def = GraphIndexBuilder::new();
8600        def.push(dict.encode(&node(0), &p, &node(1)).unwrap());
8601        let mut g = GraphIndexBuilder::new().with_tile_budget(256); // many tiles
8602        for i in 0..500u32 {
8603            g.push(dict.encode(&node(1000 + i), &p, &node(2000 + i)).unwrap());
8604        }
8605        let named = vec![("<http://ex/g>".to_string(), g.build())];
8606        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
8607        let rete = Rete::open(&bytes).unwrap();
8608        let header = rete.header();
8609
8610        // The single entry's container range: count varint, iri_len varint,
8611        // iri, container_len varint, then the container.
8612        let soff = header.named_graphs_offset as usize;
8613        let send = soff + header.named_graphs_len as usize;
8614        let sec = &bytes[soff..send];
8615        let (n, mut pos) = read_uvarint(sec).unwrap();
8616        assert_eq!(n, 1);
8617        let (ilen, u1) = read_uvarint(&sec[pos..]).unwrap();
8618        pos += u1 + ilen as usize;
8619        let (clen, u2) = read_uvarint(&sec[pos..]).unwrap();
8620        pos += u2;
8621        let container = ByteRange {
8622            offset: (soff + pos) as u64,
8623            len: clen,
8624        };
8625
8626        use crate::reader::SliceReader;
8627        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
8628        let reader: std::sync::Arc<dyn RangeReader + Send + Sync> =
8629            std::sync::Arc::new(SliceReader::new(leaked));
8630        let (lazy_idx, _, _) = open_index_container_lazy(
8631            &reader,
8632            container,
8633            header.block_codec,
8634            header.has_tile_synopsis(),
8635            1,
8636            header.perms,
8637            header.perms.len(),
8638        )
8639        .unwrap();
8640        let mut want = rete
8641            .graph_index("<http://ex/g>")
8642            .unwrap()
8643            .match_pattern((None, None, None));
8644        let mut got = lazy_idx.match_pattern((None, None, None));
8645        want.sort_unstable();
8646        got.sort_unstable();
8647        assert_eq!(got.len(), 500);
8648        assert_eq!(got, want);
8649        assert!(!lazy_idx.load_incomplete());
8650    }
8651
8652    /// `dump_iter` must agree with `dump` exactly — and must be *lazy*: taking
8653    /// the first triple of a lazily range-read file may not drag the whole
8654    /// index across the reader, or the JS client's streaming quad cursor (which
8655    /// is this iterator, suspended between wasm calls) would be a materializing
8656    /// dump wearing a cursor's clothes.
8657    #[test]
8658    fn dump_iter_matches_dump_and_stops_early() {
8659        use crate::reader::{CountingReader, SliceReader};
8660
8661        // Enough triples over tiny tiles to force many tiles per permutation.
8662        let triples: Vec<(String, String, String)> = (0..2000u32)
8663            .map(|i| {
8664                (
8665                    format!("<http://ex/s/{i:04}>"),
8666                    "<http://ex/p>".to_string(),
8667                    format!("<http://ex/o/{i:04}>"),
8668                )
8669            })
8670            .collect();
8671        let mut db = DictionaryBuilder::new();
8672        for (s, p, o) in &triples {
8673            db.observe(s, p, o);
8674        }
8675        let dict = db.build();
8676        let mut ib = GraphIndexBuilder::new().with_tile_budget(256);
8677        for (s, p, o) in &triples {
8678            ib.push(dict.encode(s, p, o).unwrap());
8679        }
8680        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
8681
8682        // Same triples, same order, as the eager dump.
8683        let rete = Rete::open(&bytes).unwrap();
8684        assert_eq!(rete.dump_iter(None).collect::<Vec<_>>(), rete.dump(None));
8685        // A missing named graph yields nothing rather than the default graph.
8686        assert_eq!(rete.dump_iter(Some("http://ex/nope")).count(), 0);
8687
8688        // Lazily: taking one triple must cost far fewer bytes than draining.
8689        let read_bytes = |take: usize| -> u64 {
8690            let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
8691            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
8692            let rete = Rete::open_ranged_lazy(reader.clone()).unwrap();
8693            let before = reader.bytes_read();
8694            let n = rete.dump_iter(None).take(take).count();
8695            assert_eq!(n, take.min(triples.len()));
8696            assert!(!rete.index_incomplete());
8697            reader.bytes_read() - before
8698        };
8699        let first_only = read_bytes(1);
8700        let everything = read_bytes(triples.len());
8701        // Always true: stopping after one triple cannot cost a full scan.
8702        assert!(
8703            first_only < everything,
8704            "dump_iter is not lazy: one triple read {first_only} B, a full scan {everything} B"
8705        );
8706        // The STRONG claim — under half — only holds once the payload dwarfs the
8707        // fixed open cost. This fixture is ~32 KB, and with `compression` off the
8708        // dictionary is essentially the whole file, so opening it at all already
8709        // pays most of `everything` (21 KB of 33 KB) and the ratio says nothing
8710        // about laziness. Asserting it unconditionally is how this test failed on
8711        // `--no-default-features` while passing everywhere else.
8712        #[cfg(feature = "compression")]
8713        assert!(
8714            first_only * 2 < everything,
8715            "dump_iter is not lazy: one triple read {first_only} B of the {everything} B a full scan reads"
8716        );
8717
8718        // A FRESH open: these assertions fault tiles and the dictionary in,
8719        // and the laziness measurement above counts bytes on a COLD reader —
8720        // sharing one `rete` makes first_only and everything both ~0, so the
8721        // comparison silently stops testing anything.
8722        let fresh = Rete::open(&bytes).unwrap();
8723        // `dump_batch` is the resumable form the language clients pull through.
8724        // Driven to exhaustion it must reproduce the eager dump EXACTLY — same
8725        // triples, same order — for any batch size, including one so small that
8726        // every call ends on a subject boundary before reaching it.
8727        let drain = |max_quads: usize| {
8728            let (mut out, mut cursor, mut calls) = (Vec::new(), 0u32, 0);
8729            loop {
8730                let (batch, next, done) = fresh.dump_batch(None, cursor, max_quads);
8731                out.extend(batch);
8732                cursor = next;
8733                calls += 1;
8734                assert!(calls < 10_000, "cursor failed to advance");
8735                if done {
8736                    break out;
8737                }
8738            }
8739        };
8740        let eager = fresh.dump(None);
8741        for max in [1usize, 7, 128, 100_000] {
8742            assert_eq!(drain(max), eager, "batch size {max} changed the dump");
8743        }
8744        // An unknown graph is an empty, already-finished dump — not an error and
8745        // not the default graph leaking through.
8746        let (t, _, done) = fresh.dump_batch(Some("http://ex/nope"), 0, 16);
8747        assert!(t.is_empty() && done);
8748    }
8749
8750    /// `query_batch` is `dump_batch` generalized to a PATTERN — the primitive the
8751    /// Java client's RDF4J cursor pulls through, one bounded batch per wasm call.
8752    /// Three things have to hold or the cursor is a materializing scan wearing a
8753    /// cursor's clothes: driven to exhaustion it must reproduce the eager
8754    /// `query_in_graph` for every pattern shape and every batch size; taking one
8755    /// row must cost far fewer bytes than draining; and every call must either
8756    /// yield a row or say `done`, so the caller can never spin.
8757    #[test]
8758    fn query_batch_matches_query_in_graph_and_stops_early() {
8759        use crate::reader::{CountingReader, SliceReader};
8760
8761        // Enough triples over tiny tiles that a scan spans many tiles, with two
8762        // predicates and a repeated subject so bound patterns have real fanout.
8763        let triples: Vec<(String, String, String)> = (0..2000u32)
8764            .map(|i| {
8765                (
8766                    format!("<http://ex/s/{:04}>", i / 2),
8767                    format!("<http://ex/p/{}>", i % 2),
8768                    format!("<http://ex/o/{i:04}>"),
8769                )
8770            })
8771            .collect();
8772        let mut db = DictionaryBuilder::new();
8773        for (s, p, o) in &triples {
8774            db.observe(s, p, o);
8775        }
8776        let dict = db.build();
8777        let mut ib = GraphIndexBuilder::new().with_tile_budget(256);
8778        for (s, p, o) in &triples {
8779            ib.push(dict.encode(s, p, o).unwrap());
8780        }
8781        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
8782        let rete = Rete::open(&bytes).unwrap();
8783
8784        let drain = |s: Option<&str>, p: Option<&str>, o: Option<&str>, max: usize| {
8785            let (mut out, mut cursor, mut calls) = (Vec::new(), 0u64, 0);
8786            loop {
8787                let (batch, next, done) = rete.query_batch(None, s, p, o, cursor, max);
8788                // The contract the client relies on: rows, or done. Never
8789                // "nothing yet, call again" — that is an unbounded spin.
8790                assert!(!batch.is_empty() || done, "empty non-final batch");
8791                out.extend(batch);
8792                cursor = next;
8793                calls += 1;
8794                assert!(calls < 20_000, "cursor failed to advance");
8795                if done {
8796                    break out;
8797                }
8798            }
8799        };
8800        let shapes: [(Option<&str>, Option<&str>, Option<&str>); 6] = [
8801            (None, None, None),
8802            (Some("<http://ex/s/0007>"), None, None),
8803            (None, Some("<http://ex/p/1>"), None),
8804            (None, None, Some("<http://ex/o/0042>")),
8805            (Some("<http://ex/s/0007>"), Some("<http://ex/p/0>"), None),
8806            (
8807                Some("<http://ex/s/0007>"),
8808                Some("<http://ex/p/0>"),
8809                Some("<http://ex/o/0014>"),
8810            ),
8811        ];
8812        for (s, p, o) in shapes {
8813            let eager = rete.query_in_graph(None, s, p, o);
8814            assert!(
8815                !eager.is_empty(),
8816                "fixture shape {s:?} {p:?} {o:?} matches nothing"
8817            );
8818            // The lazy pull form agrees exactly, order included.
8819            assert_eq!(
8820                rete.query_iter(None, s, p, o).collect::<Vec<_>>(),
8821                eager,
8822                "query_iter disagrees for {s:?} {p:?} {o:?}"
8823            );
8824            for max in [1usize, 3, 64, 100_000] {
8825                let mut got = drain(s, p, o, max);
8826                let mut want = eager.clone();
8827                // A batch is re-sorted canonically per batch, not globally, so
8828                // a bound pattern's batches can arrive in permutation order.
8829                got.sort();
8830                want.sort();
8831                assert_eq!(got, want, "batch size {max} changed {s:?} {p:?} {o:?}");
8832            }
8833            // The fully unbound scan routes to SPO, so batching is order-exact.
8834            if s.is_none() && p.is_none() && o.is_none() {
8835                assert_eq!(drain(s, p, o, 7), eager, "unbound batching reordered rows");
8836            }
8837        }
8838
8839        // A bound term the dictionary has never seen, and an unknown graph, are
8840        // both finished-and-empty rather than errors or a leak of everything.
8841        let (t, _, done) = rete.query_batch(None, Some("<http://ex/nope>"), None, None, 0, 16);
8842        assert!(t.is_empty() && done);
8843        let (t, _, done) = rete.query_batch(Some("http://ex/nope"), None, None, None, 0, 16);
8844        assert!(t.is_empty() && done);
8845        assert_eq!(
8846            rete.query_iter(None, Some("<http://ex/nope>"), None, None)
8847                .count(),
8848            0
8849        );
8850
8851        // Lazily: one row must not drag the whole scan across the reader. This
8852        // is the property that makes `LIMIT 1` bounded on a 48 GiB file.
8853        let read_bytes = |take: usize| -> u64 {
8854            let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
8855            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
8856            let rete = Rete::open_ranged_lazy(reader.clone()).unwrap();
8857            let before = reader.bytes_read();
8858            let (mut n, mut cursor) = (0usize, 0u64);
8859            loop {
8860                let (batch, next, done) = rete.query_batch(None, None, None, None, cursor, 1);
8861                n += batch.len();
8862                cursor = next;
8863                if done || n >= take {
8864                    break;
8865                }
8866            }
8867            assert!(n >= take.min(triples.len()));
8868            assert!(!rete.index_incomplete());
8869            reader.bytes_read() - before
8870        };
8871        let first_only = read_bytes(1);
8872        let everything = read_bytes(triples.len());
8873        assert!(
8874            first_only < everything,
8875            "query_batch is not lazy: one row read {first_only} B, a full scan {everything} B"
8876        );
8877        #[cfg(feature = "compression")]
8878        assert!(
8879            first_only * 2 < everything,
8880            "query_batch is not lazy: one row read {first_only} B of the {everything} B a full scan reads"
8881        );
8882    }
8883
8884    /// A batched cursor over a QUADS file must stay inside the graph it was
8885    /// given: the default graph and each named graph resume independently.
8886    #[test]
8887    fn query_batch_is_graph_scoped() {
8888        let mut db = DictionaryBuilder::new();
8889        for (s, p, o) in [
8890            ("Alice", "knows", "Bob"),
8891            ("Alice", "knows", "Carol"),
8892            ("Alice", "knows", "Dave"),
8893            ("Erin", "knows", "Frank"),
8894            ("Alice", "knows", "Zoe"),
8895        ] {
8896            db.observe(s, p, o);
8897        }
8898        let dict = db.build();
8899        let mut def = GraphIndexBuilder::new();
8900        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
8901        def.push(dict.encode("Alice", "knows", "Carol").unwrap());
8902        let mut g1 = GraphIndexBuilder::new();
8903        g1.push(dict.encode("Alice", "knows", "Dave").unwrap());
8904        g1.push(dict.encode("Erin", "knows", "Frank").unwrap());
8905        let mut g2 = GraphIndexBuilder::new();
8906        g2.push(dict.encode("Alice", "knows", "Zoe").unwrap());
8907        let named = vec![
8908            ("http://ex/g1".to_string(), g1.build()),
8909            ("http://ex/g2".to_string(), g2.build()),
8910        ];
8911        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
8912        let rete = Rete::open(&bytes).unwrap();
8913        for graph in [None, Some("http://ex/g1"), Some("http://ex/g2")] {
8914            let eager = rete.query_in_graph(graph, None, None, None);
8915            let (mut out, mut cursor) = (Vec::new(), 0u64);
8916            loop {
8917                let (batch, next, done) = rete.query_batch(graph, None, None, None, cursor, 1);
8918                out.extend(batch);
8919                cursor = next;
8920                if done {
8921                    break;
8922                }
8923            }
8924            assert_eq!(out, eager, "graph {graph:?} batched differently");
8925        }
8926    }
8927
8928    #[test]
8929    fn query_in_graph_is_graph_scoped() {
8930        // Default graph: Alice knows Bob, Alice knows Carol.
8931        // Named g1: Alice knows Dave (same predicate, different graph).
8932        let mut db = DictionaryBuilder::new();
8933        for (s, p, o) in [
8934            ("Alice", "knows", "Bob"),
8935            ("Alice", "knows", "Carol"),
8936            ("Alice", "knows", "Dave"),
8937        ] {
8938            db.observe(s, p, o);
8939        }
8940        let dict = db.build();
8941
8942        let mut def = GraphIndexBuilder::new();
8943        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
8944        def.push(dict.encode("Alice", "knows", "Carol").unwrap());
8945        let mut g1 = GraphIndexBuilder::new();
8946        g1.push(dict.encode("Alice", "knows", "Dave").unwrap());
8947
8948        let named = vec![("http://ex/g1".to_string(), g1.build())];
8949        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
8950        let rete = Rete::open(&bytes).unwrap();
8951
8952        // Default graph only: the two default-graph objects, not Dave.
8953        let mut def_objs: Vec<String> = rete
8954            .query_in_graph(None, Some("Alice"), Some("knows"), None)
8955            .into_iter()
8956            .map(|(_, _, o)| o)
8957            .collect();
8958        def_objs.sort();
8959        assert_eq!(def_objs, vec!["Bob".to_string(), "Carol".to_string()]);
8960
8961        // Named graph only: just Dave.
8962        assert_eq!(
8963            rete.query_in_graph(Some("http://ex/g1"), Some("Alice"), None, None),
8964            vec![("Alice".into(), "knows".into(), "Dave".into())]
8965        );
8966
8967        // A wildcard-everything scan is scoped to its graph.
8968        assert_eq!(rete.query_in_graph(None, None, None, None).len(), 2);
8969        assert_eq!(
8970            rete.query_in_graph(Some("http://ex/g1"), None, None, None)
8971                .len(),
8972            1
8973        );
8974
8975        // An unknown graph IRI is empty, not an error.
8976        assert!(rete
8977            .query_in_graph(Some("http://ex/missing"), None, None, None)
8978            .is_empty());
8979    }
8980
8981    /// A **bounded** `query_in_graph` — and a dump of one named graph — must not
8982    /// fault the whole dictionary on a lazily range-read file.
8983    ///
8984    /// Both used to call `Dictionary::prefetch_all`, so the graph-scoped pattern
8985    /// primitive an RDF4J `Sail` calls once per named graph paid for every term
8986    /// in the file to answer a handful of rows. Measured on `cordis.rete`
8987    /// (801 MB, a 417 MB dictionary) before the fix: a 15-row answer read
8988    /// 416 MB and peaked at 2.5 GB RSS. The fixture below is the same shape in
8989    /// miniature — long literals so the dictionary dominates the file — and the
8990    /// assertion is the invariant that regressed: bytes read must stay well
8991    /// under the dictionary section. Lazy answers are compared against eager
8992    /// throughout: cheaper is only interesting if it is also correct.
8993    #[test]
8994    fn bounded_query_in_graph_does_not_fault_the_whole_dictionary() {
8995        use crate::reader::{CountingReader, SliceReader};
8996
8997        // Long, distinct, poorly-compressible object literals: the dictionary is
8998        // the payload here, exactly as on a graph that stores abstracts or
8999        // embedded media. Repeating one filler string would not do — front
9000        // coding plus zstd shrink 2000 near-identical literals to ~6 KB, which
9001        // is smaller than the index and makes the measurement meaningless.
9002        let payload = |i: u32| -> String {
9003            let mut s = String::with_capacity(800);
9004            let mut x = i as u64 ^ 0x9e37_79b9_7f4a_7c15;
9005            while s.len() < 800 {
9006                x = x
9007                    .wrapping_mul(6364136223846793005)
9008                    .wrapping_add(1442695040888963407);
9009                s.push_str(&format!("{:016x}", x ^ (x >> 29)));
9010            }
9011            s
9012        };
9013        // Half the quads go to the named graph, and the two halves are kept
9014        // lexically apart (`a…` / `b…`) so each lands in its own dictionary
9015        // chunks — the real layout on a quads file whose graphs use different
9016        // IRI namespaces, e.g. `cordis.rete`. Interleaving them instead would
9017        // put a term of both graphs in every chunk, and then no per-graph
9018        // access pattern could avoid faulting the whole dictionary.
9019        let quads: Vec<(String, String, String, bool)> = (0..2000u32)
9020            .map(|i| {
9021                let named = i % 2 == 0;
9022                let ns = if named { 'a' } else { 'b' };
9023                (
9024                    format!("<http://ex/{ns}/s/{i:04}>"),
9025                    "<http://ex/abstract>".to_string(),
9026                    format!("\"{ns}{} number {i}\"", payload(i)),
9027                    named,
9028                )
9029            })
9030            .collect();
9031        let mut db = DictionaryBuilder::new();
9032        for (s, p, o, _) in &quads {
9033            db.observe(s, p, o);
9034        }
9035        let dict = db.build();
9036        let mut def = GraphIndexBuilder::new().with_tile_budget(256);
9037        let mut g1 = GraphIndexBuilder::new().with_tile_budget(256);
9038        for (s, p, o, named) in &quads {
9039            let t = dict.encode(s, p, o).unwrap();
9040            if *named {
9041                g1.push(t);
9042            } else {
9043                def.push(t);
9044            }
9045        }
9046        let named = vec![("http://ex/g1".to_string(), g1.build())];
9047        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
9048
9049        let eager = Rete::open(&bytes).unwrap();
9050        let dict_len = eager.header().dictionary_len;
9051
9052        // `probe` runs one closure against a COLD lazy open and reports the
9053        // bytes it faulted beyond the open itself.
9054        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
9055        let probe = |f: &dyn Fn(&Rete) -> Vec<TermTriple>| -> (Vec<TermTriple>, u64) {
9056            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
9057            let lazy = Rete::open_ranged_lazy(reader.clone()).unwrap();
9058            let before = reader.bytes_read();
9059            let out = f(&lazy);
9060            assert!(!lazy.index_incomplete(), "a chunk fetch failed");
9061            (out, reader.bytes_read() - before)
9062        };
9063
9064        // 1. One bound subject in the named graph.
9065        let want = eager.query_in_graph(
9066            Some("http://ex/g1"),
9067            Some("<http://ex/a/s/0100>"),
9068            None,
9069            None,
9070        );
9071        assert_eq!(want.len(), 1, "fixture: expected exactly one match");
9072        let (got, pulled) = probe(&|r| {
9073            r.query_in_graph(
9074                Some("http://ex/g1"),
9075                Some("<http://ex/a/s/0100>"),
9076                None,
9077                None,
9078            )
9079        });
9080        assert_eq!(got, want, "lazy query_in_graph disagrees with eager");
9081        assert!(
9082            pulled * 4 < dict_len,
9083            "a 1-row query_in_graph faulted {pulled} B of a {dict_len} B dictionary — \
9084             it is prefetching the whole thing again"
9085        );
9086
9087        // 2. A graph with NOTHING to resolve must not fault the dictionary.
9088        //    Both shapes below used to pull every chunk before returning an
9089        //    empty answer, and `rete export` hits the second one first on a
9090        //    quads file whose default graph is empty — on `cordis.rete` that
9091        //    was 415 MB read and 2.5 GB of peak RSS to emit zero triples. What
9092        //    is left is the named-graph directory walk it takes to learn the
9093        //    graph is absent at all; that is index, not dictionary.
9094        let (empty, q_pulled) =
9095            probe(&|r| r.query_in_graph(Some("http://ex/nope"), None, None, None));
9096        assert!(empty.is_empty());
9097        let (_, d_pulled) = probe(&|r| {
9098            let mut n = 0usize;
9099            r.dump_each(Some("http://ex/nope"), |_, _, _| n += 1);
9100            assert_eq!(n, 0, "an absent graph yielded triples");
9101            Vec::new()
9102        });
9103        assert_eq!(
9104            q_pulled, d_pulled,
9105            "query_in_graph and dump_each disagree on what an absent graph costs"
9106        );
9107        assert!(
9108            q_pulled * 4 < dict_len,
9109            "an absent graph faulted {q_pulled} B of a {dict_len} B dictionary"
9110        );
9111
9112        // 3. Dumping ONE named graph costs that graph, not the whole file: the
9113        //    default graph's half of the dictionary must stay unfaulted.
9114        let want: Vec<TermTriple> = eager.dump(Some("http://ex/g1"));
9115        assert_eq!(want.len(), 1000);
9116        let (got, pulled) = probe(&|r| {
9117            let mut out = Vec::new();
9118            r.dump_each(Some("http://ex/g1"), |s, p, o| {
9119                out.push((s.to_string(), p.to_string(), o.to_string()))
9120            });
9121            out
9122        });
9123        assert_eq!(got, want, "lazy dump_each disagrees with eager dump");
9124        assert!(
9125            pulled * 3 < dict_len * 2,
9126            "dumping half the graph faulted {pulled} B of a {dict_len} B dictionary — \
9127             the other graph's chunks are being pulled in too"
9128        );
9129    }
9130
9131    /// Build a multi-chunk single-graph ranged file image for the eviction
9132    /// tests: 4000 distinct ~70-byte object literals overflow the 64 KiB
9133    /// dictionary chunk budget into several chunks, and one multi-KiB literal
9134    /// forces a large run/chunk boundary — the shape a tiny budget must survive.
9135    #[cfg(test)]
9136    fn build_multichunk_image() -> Vec<u8> {
9137        use crate::index::PermSet;
9138        let mut db = DictionaryBuilder::new();
9139        let mut triples: Vec<(String, String, String)> = (0..4000u32)
9140            .map(|i| {
9141                (
9142                    format!("<http://ex/s/{i:07}>"),
9143                    format!("<http://ex/p/{}>", i % 8),
9144                    format!("\"object literal number {i:07} with padding text to widen the term\""),
9145                )
9146            })
9147            .collect();
9148        triples.push((
9149            "<http://ex/s/big>".to_string(),
9150            "<http://ex/p/big>".to_string(),
9151            format!("\"{}\"", "Z".repeat(8000)),
9152        ));
9153        for (s, p, o) in &triples {
9154            db.observe(s, p, o);
9155        }
9156        let dict = db.build();
9157        let mut idx = GraphIndexBuilder::new()
9158            .with_tile_budget(64)
9159            .with_perms(PermSet::ALL);
9160        for (s, p, o) in &triples {
9161            idx.push(dict.encode(s, p, o).unwrap());
9162        }
9163        write_dataset(&dict, &idx.build(), &[], true, &[], 0)
9164    }
9165
9166    /// **Bounded-export phase 2: eviction is byte-transparent, and progress is
9167    /// guaranteed at any cap.** A cap far below the decompressed dictionary
9168    /// forces the chunk cache to evict during a full dump, yet the dump is
9169    /// byte-for-byte the unlimited-cap dump — the `Arc` handout keeps every
9170    /// chunk alive across its one decode, so eviction never drops bytes in use.
9171    /// A 1-byte cap (smaller than any single chunk, so every insert evicts
9172    /// itself immediately) still completes with identical output: it is the
9173    /// handout invariant, not residency, that makes a window make progress.
9174    #[test]
9175    fn tiny_dict_cache_evicts_yet_dump_is_byte_identical() {
9176        use crate::reader::SliceReader;
9177        let leaked: &'static [u8] = Box::leak(build_multichunk_image().into_boxed_slice());
9178        let dump = |cap: u64| -> (
9179            Vec<(String, String, String)>,
9180            crate::chunk_cache::CacheStats,
9181        ) {
9182            let rete = Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap();
9183            rete.dict.set_cache_cap(cap);
9184            let mut out = Vec::new();
9185            rete.dump_filtered_each(None, None, None, None, |s, p, o| {
9186                out.push((s.to_string(), p.to_string(), o.to_string()))
9187            });
9188            assert!(
9189                !rete.index_incomplete(),
9190                "no lazy fault should fail at cap {cap}"
9191            );
9192            let stats = rete.dict_cache_stats();
9193            (out, stats)
9194        };
9195
9196        let (unlimited, us) = dump(u64::MAX);
9197        assert_eq!(unlimited.len(), 4001, "every triple dumped");
9198        assert_eq!(us.evictions, 0, "unlimited cap evicts nothing");
9199
9200        // A cap far below the decompressed dictionary: eviction happens, output
9201        // unchanged.
9202        let (tiny, ts) = dump(128 * 1024);
9203        assert_eq!(tiny, unlimited, "128 KiB-cap dump differs from unlimited");
9204        assert!(ts.evictions > 0, "a tiny cap must evict (stats {ts:?})");
9205
9206        // Progress guarantee: a 1-byte cap still completes, byte-identical.
9207        let (starved, ss) = dump(1);
9208        assert_eq!(starved, unlimited, "1-byte-cap dump differs from unlimited");
9209        assert!(
9210            ss.evictions > 0,
9211            "1-byte cap evicts every insert (stats {ss:?})"
9212        );
9213    }
9214
9215    /// **The budget pays for the open's fixed directories.** A ranged open holds
9216    /// one descriptor per dictionary chunk and per index tile for its whole
9217    /// life, outside every cache — 88 MB on the 14.75 GiB openaire-relation,
9218    /// 224 MB on epfl-infoscience. `set_memory_budget` used to give the caches
9219    /// the whole budget on top of that; it now measures the directories
9220    /// ([`Rete::fixed_overhead`]) and takes them out of the dictionary share, so
9221    /// caps + directories stay within the total. This checks the arithmetic on
9222    /// a many-chunk file, the 64 MiB floor, the unlimited pass-through, and that
9223    /// a dump under the capped budget keeps the dictionary cache within its
9224    /// (net) share.
9225    #[test]
9226    fn memory_budget_charges_the_fixed_directories() {
9227        use crate::reader::SliceReader;
9228        let leaked: &'static [u8] = Box::leak(build_multichunk_image().into_boxed_slice());
9229        let rete = Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap();
9230
9231        let fixed = rete.fixed_overhead();
9232        // Every chunk and every tile has a descriptor; a multi-chunk dictionary
9233        // and a 64-triple tile budget make both directories non-trivial.
9234        assert!(
9235            fixed.dict_directory > 0 && fixed.index_directory > 0,
9236            "{fixed:?}"
9237        );
9238        assert_eq!(fixed.named_graphs, 0, "no named graphs in this file");
9239        assert_eq!(
9240            fixed.total(),
9241            fixed.dict_directory + fixed.index_directory + fixed.named_graphs
9242        );
9243
9244        // A budget well above the floor: the dictionary share is the plain
9245        // split less the directories, exactly.
9246        let total: u64 = 512 << 20;
9247        let applied = rete.set_memory_budget(Some(total));
9248        let plain = split_memory_budget(total);
9249        assert_eq!(applied.dict_cache, plain.dict_cache - fixed.total());
9250        assert_eq!(rete.dict_cache_cap(), applied.dict_cache);
9251        assert_eq!(applied.tile_cache, plain.tile_cache);
9252        assert_eq!(applied.block_cache, plain.block_cache);
9253        assert!(
9254            applied.dict_cache + applied.tile_cache + applied.block_cache + fixed.total() <= total,
9255            "caps + fixed directories exceed the budget: {applied:?} + {fixed:?} > {total}"
9256        );
9257
9258        // A budget whose share would fall under the floor keeps the floor.
9259        let applied = rete.set_memory_budget(Some(1 << 20));
9260        assert_eq!(applied.dict_cache, MIN_DICT_CACHE_CAP);
9261
9262        // Unlimited stays unlimited: nothing is subtracted from u64::MAX.
9263        let applied = rete.set_memory_budget(None);
9264        assert_eq!(applied.dict_cache, u64::MAX);
9265        assert_eq!(rete.dict_cache_cap(), u64::MAX);
9266
9267        // Under a cap smaller than the decompressed dictionary, a full dump
9268        // keeps the cache within it.
9269        let rete = Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap();
9270        rete.dict.set_cache_cap(128 * 1024);
9271        let mut rows = 0usize;
9272        rete.dump_filtered_each(None, None, None, None, |_, _, _| rows += 1);
9273        assert_eq!(rows, 4001);
9274        let s = rete.dict_cache_stats();
9275        assert!(s.evictions > 0, "the cap must bind: {s:?}");
9276        // One oversized chunk may sit alone above the cap (the handout
9277        // invariant); otherwise residency is within it.
9278        assert!(
9279            s.entries <= 1 || s.resident_bytes <= 128 * 1024,
9280            "dictionary cache over its cap after the dump: {s:?}"
9281        );
9282    }
9283
9284    /// **Bounded-export phase 2: per-graph index release.** After a named
9285    /// graph's slot is exported, [`Rete::release_named_graph`] drops its decoded
9286    /// index (and its tiles) so a many-graph dump holds one graph's index at a
9287    /// time, not all of them. The graph re-opens lazily if touched again.
9288    #[test]
9289    fn releasing_a_named_graph_drops_and_reopens_its_index() {
9290        use crate::index::PermSet;
9291        use crate::reader::SliceReader;
9292        let mut db = DictionaryBuilder::new();
9293        let all: Vec<(String, String, String)> = (0..40u32)
9294            .map(|i| {
9295                (
9296                    format!("<http://ex/s{}>", i % 7),
9297                    format!("<http://ex/p{}>", i % 3),
9298                    format!("<http://ex/o{}>", i % 5),
9299                )
9300            })
9301            .collect();
9302        for (s, p, o) in &all {
9303            db.observe(s, p, o);
9304        }
9305        let dict = db.build();
9306        let mk = |take: fn(u32) -> bool| {
9307            let mut g = GraphIndexBuilder::new()
9308                .with_tile_budget(16)
9309                .with_perms(PermSet::ALL);
9310            for (i, (s, p, o)) in all.iter().enumerate() {
9311                if take(i as u32) {
9312                    g.push(dict.encode(s, p, o).unwrap());
9313                }
9314            }
9315            g.build()
9316        };
9317        let def = mk(|_| true);
9318        let g1 = mk(|i| i % 2 == 0);
9319        let g2 = mk(|i| i % 3 == 0);
9320        let image = write_dataset(
9321            &dict,
9322            &def,
9323            &[
9324                ("<http://ex/g1>".to_string(), g1),
9325                ("<http://ex/g2>".to_string(), g2),
9326            ],
9327            true,
9328            &[],
9329            0,
9330        );
9331        let leaked: &'static [u8] = Box::leak(image.into_boxed_slice());
9332        let mut rete = Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap();
9333
9334        let opened = |r: &Rete| -> usize {
9335            match &r.named_graphs {
9336                NamedGraphsSlot::Lazy(l) => {
9337                    let mut n = 0;
9338                    l.for_each_opened(|_| n += 1);
9339                    n
9340                }
9341                _ => 0,
9342            }
9343        };
9344
9345        assert_eq!(opened(&rete), 0, "nothing opened before first touch");
9346        // Touching g1 opens its index.
9347        assert!(rete.graph_index("<http://ex/g1>").is_some());
9348        assert_eq!(opened(&rete), 1, "g1 opened");
9349        // Release it: the index is dropped.
9350        rete.release_named_graph("<http://ex/g1>");
9351        assert_eq!(opened(&rete), 0, "g1 released");
9352        // Releasing an unknown / already-released graph is a no-op.
9353        rete.release_named_graph("<http://ex/absent>");
9354        rete.release_named_graph("<http://ex/g1>");
9355        assert_eq!(opened(&rete), 0);
9356        // The graph re-opens lazily on the next access.
9357        assert!(rete.graph_index("<http://ex/g1>").is_some());
9358        assert_eq!(opened(&rete), 1, "g1 re-opened after release");
9359    }
9360
9361    /// **The filtered-dump correctness invariant** (#117 point 1): for every
9362    /// graph and every one of the eight bound/unbound shapes,
9363    /// `dump_filtered_each` must return *exactly* the quads `dump_each` returns,
9364    /// filtered — no more, and above all no fewer.
9365    ///
9366    /// "No fewer" is the whole risk. The speedup comes from *not fetching* tiles
9367    /// a synopsis says cannot match, and a pruning bug does not crash, it
9368    /// silently returns a shorter answer. So the oracle here is the unfiltered
9369    /// dump with a plain Rust `retain` over it — the thing the filter is meant
9370    /// to be a cheaper spelling of — and it is checked on every open path, since
9371    /// the eager path prunes with a different code path than the lazy one.
9372    ///
9373    /// It runs on a `PermSet::CORE` (`--permutations 3`) file as well as a
9374    /// six-permutation one. Every shape routes inside CORE — `perm_routing_never_leaves_core`
9375    /// proves the routing table does — so the two must agree row for row, and if
9376    /// a future shape ever routed outside CORE this test fails on the three-perm
9377    /// build rather than silently returning nothing from an absent section.
9378    #[test]
9379    fn filtered_dump_is_exactly_the_unfiltered_dump_filtered() {
9380        use crate::index::PermSet;
9381        use crate::reader::SliceReader;
9382
9383        let terms = |i: u32| {
9384            (
9385                format!("<http://ex/s{}>", i % 11),
9386                format!("<http://ex/p{}>", i % 3),
9387                // Mix IRIs and literals in the object column: a literal lives in
9388                // a different dictionary section than a node, and the object
9389                // filter has to resolve through the right one.
9390                if i.is_multiple_of(4) {
9391                    format!("\"lit {}\"@en", i % 5)
9392                } else {
9393                    format!("<http://ex/o{}>", i % 7)
9394                },
9395            )
9396        };
9397        let build = |perms: PermSet| {
9398            let mut db = DictionaryBuilder::new();
9399            let all: Vec<_> = (0..90u32).map(terms).collect();
9400            for (s, p, o) in &all {
9401                db.observe(s, p, o);
9402            }
9403            let dict = db.build();
9404            let mut def = GraphIndexBuilder::new()
9405                .with_tile_budget(16)
9406                .with_perms(perms);
9407            let mut g1 = GraphIndexBuilder::new()
9408                .with_tile_budget(16)
9409                .with_perms(perms);
9410            for (i, (s, p, o)) in all.iter().enumerate() {
9411                let t = dict.encode(s, p, o).unwrap();
9412                // Deliberately overlapping, not disjoint: every second triple is
9413                // in both graphs, so a filter that leaked across graphs would
9414                // still look plausible on counts alone.
9415                if i % 2 == 0 {
9416                    g1.push(t);
9417                }
9418                def.push(t);
9419            }
9420            write_dataset(
9421                &dict,
9422                &def.build(),
9423                &[("<http://ex/g1>".to_string(), g1.build())],
9424                true,
9425                &[],
9426                0,
9427            )
9428        };
9429
9430        let shapes: Vec<(Option<&str>, Option<&str>, Option<&str>)> = vec![
9431            (None, None, None),
9432            (Some("<http://ex/s3>"), None, None),
9433            (None, Some("<http://ex/p1>"), None),
9434            (None, None, Some("<http://ex/o5>")),
9435            (None, None, Some("\"lit 0\"@en")),
9436            (Some("<http://ex/s3>"), Some("<http://ex/p0>"), None),
9437            (Some("<http://ex/s4>"), None, Some("<http://ex/o4>")),
9438            (None, Some("<http://ex/p1>"), Some("<http://ex/o5>")),
9439            (
9440                Some("<http://ex/s3>"),
9441                Some("<http://ex/p0>"),
9442                Some("<http://ex/o3>"),
9443            ),
9444            // Terms the dictionary has never seen, in each position: these must
9445            // return nothing, not everything (an unresolvable bound term must
9446            // not silently degrade to "unbound").
9447            (Some("<http://ex/nope>"), None, None),
9448            (None, Some("<http://ex/nope>"), None),
9449            (None, None, Some("<http://ex/nope>")),
9450        ];
9451
9452        let mut any_pruned = false;
9453        for perms in [PermSet::ALL, PermSet::CORE] {
9454            let image = build(perms);
9455            let leaked: &'static [u8] = Box::leak(image.clone().into_boxed_slice());
9456            let opens: Vec<(&str, Rete)> = vec![
9457                ("resident", Rete::open(&image).unwrap()),
9458                (
9459                    "ranged",
9460                    Rete::open_ranged(&SliceReader::new(leaked)).unwrap(),
9461                ),
9462                (
9463                    "lazy",
9464                    Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap(),
9465                ),
9466            ];
9467            for graph in [None, Some("<http://ex/g1>"), Some("<http://ex/absent>")] {
9468                // The oracle: the unfiltered dump, filtered in Rust.
9469                let full: Vec<TermTriple> = {
9470                    let mut v = Vec::new();
9471                    Rete::open(&image).unwrap().dump_each(graph, |s, p, o| {
9472                        v.push((s.to_string(), p.to_string(), o.to_string()))
9473                    });
9474                    v.sort();
9475                    v
9476                };
9477                for &(s, p, o) in &shapes {
9478                    let mut want = full.clone();
9479                    want.retain(|(ts, tp, to)| {
9480                        s.is_none_or(|x| x == ts)
9481                            && p.is_none_or(|x| x == tp)
9482                            && o.is_none_or(|x| x == to)
9483                    });
9484                    for (path, rete) in &opens {
9485                        let mut got = Vec::new();
9486                        rete.dump_filtered_each(graph, s, p, o, |s, p, o| {
9487                            got.push((s.to_string(), p.to_string(), o.to_string()))
9488                        });
9489                        got.sort();
9490                        assert_eq!(
9491                            got,
9492                            want,
9493                            "{path}/{perms:?} graph={graph:?} pattern={:?} — filtered dump \
9494                             is not the unfiltered dump filtered",
9495                            (s, p, o)
9496                        );
9497                        assert!(!rete.index_incomplete(), "{path}: a fetch failed");
9498                    }
9499                    // …and the plan must agree with what the dump actually did:
9500                    // an empty answer means either no scan at all, or a scan
9501                    // whose admitted tiles simply held nothing.
9502                    let plan = opens[2].1.dump_plan(graph, s, p, o);
9503                    if graph == Some("<http://ex/absent>")
9504                        || [s, p, o].contains(&Some("<http://ex/nope>"))
9505                    {
9506                        assert!(
9507                            plan.scan.is_none(),
9508                            "an unmatchable dump still planned a scan: {:?}",
9509                            (graph, s, p, o)
9510                        );
9511                    } else {
9512                        let scan = plan.scan.expect("a matchable dump plans a scan");
9513                        assert!(
9514                            PermSet::CORE.contains(scan.permutation),
9515                            "a dump routed to {:?}, outside PermSet::CORE — a \
9516                             --permutations 3 file has no such section",
9517                            scan.permutation
9518                        );
9519                        assert!(scan.tiles_admitted <= scan.tiles_routed);
9520                        assert!(scan.tiles_routed <= scan.tiles_total);
9521                        assert!(scan.tile_bytes <= scan.section_bytes);
9522                        if scan.tiles_admitted < scan.tiles_total {
9523                            any_pruned = true;
9524                        }
9525                    }
9526                }
9527            }
9528        }
9529        assert!(
9530            any_pruned,
9531            "no shape pruned a single tile — the fixture is too small to be \
9532             measuring anything"
9533        );
9534    }
9535
9536    /// The point of the filter: a predicate-scoped dump of a lazily range-read
9537    /// file must **fetch less** than the same graph's full dump, not merely
9538    /// return less.
9539    ///
9540    /// The fixture is the shape the win shows on — many tiles, and a predicate
9541    /// whose triples live in a minority of them — so the assertion can be about
9542    /// bytes rather than about the code path being taken on faith. Measured on
9543    /// the real thing (`cordis.rete`, 801 MB): one predicate of one named graph
9544    /// read 16.0 MB where the graph's full dump read 375.8 MB.
9545    #[test]
9546    fn a_predicate_scoped_dump_fetches_less_than_the_graph() {
9547        use crate::reader::{CountingReader, SliceReader};
9548
9549        // 200 subjects × 20 predicates, tiled small so there are many tiles, and
9550        // one distinct object per cell so that a predicate slice needs a
9551        // twentieth of the object dictionary rather than all of it — otherwise
9552        // the dictionary, which this change does not prune, swamps the index
9553        // saving the test is about.
9554        let mut db = DictionaryBuilder::new();
9555        let mut all = Vec::new();
9556        for s in 0..200u32 {
9557            for p in 0..20u32 {
9558                let t = (
9559                    format!("<http://ex/s/{s:04}>"),
9560                    format!("<http://ex/p/{p:02}>"),
9561                    format!("\"object {s:04}/{p:02}\""),
9562                );
9563                db.observe(&t.0, &t.1, &t.2);
9564                all.push(t);
9565            }
9566        }
9567        let dict = db.build();
9568        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
9569        for (s, p, o) in &all {
9570            ib.push(dict.encode(s, p, o).unwrap());
9571        }
9572        let image = write_dataset(&dict, &ib.build(), &[], false, &[], 0);
9573        let leaked: &'static [u8] = Box::leak(image.clone().into_boxed_slice());
9574
9575        // Bytes faulted past the open, for one dump.
9576        let probe = |s: Option<&str>, p: Option<&str>, o: Option<&str>| -> (usize, u64) {
9577            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
9578            let lazy = Rete::open_ranged_lazy(reader.clone()).unwrap();
9579            let before = reader.bytes_read();
9580            let mut n = 0usize;
9581            lazy.dump_filtered_each(None, s, p, o, |_, _, _| n += 1);
9582            assert!(!lazy.index_incomplete());
9583            (n, reader.bytes_read() - before)
9584        };
9585
9586        let (full_rows, full_bytes) = probe(None, None, None);
9587        let (pred_rows, pred_bytes) = probe(None, Some("<http://ex/p/07>"), None);
9588        let (subj_rows, subj_bytes) = probe(Some("<http://ex/s/0021>"), None, None);
9589        assert_eq!(full_rows, 4000);
9590        assert_eq!(pred_rows, 200, "one predicate covers every subject once");
9591        assert_eq!(subj_rows, 20);
9592        // True in any build: a slice fetches strictly less than the graph.
9593        assert!(
9594            pred_bytes < full_bytes && subj_bytes < full_bytes,
9595            "a slice fetched {pred_bytes} / {subj_bytes} B where the whole graph fetched \
9596             {full_bytes} B — the scan is not being pruned at all"
9597        );
9598        // The SIZE of the win is a property of the FILE, not of this change: it is
9599        // the index share of what a dump reads, and the dictionary — which no
9600        // filter prunes — is the rest. With the default `compression` feature the
9601        // dictionary chunk bodies are zstd-compressed and the slice costs about a
9602        // tenth of the graph (measured here: 1,565 B for the predicate and 864 B
9603        // for the subject, against 15,714 B). Built `--no-default-features` those
9604        // same chunks are stored RAW, so resolving 200 rows' terms dominates and
9605        // the identical, identically-pruned scan costs 22,225 B of 40,165 — 55%.
9606        // Asserting the strong ratio unconditionally would be asserting that zstd
9607        // is enabled; the index-side prune is checked below and holds either way.
9608        #[cfg(feature = "compression")]
9609        assert!(
9610            pred_bytes * 4 < full_bytes && subj_bytes * 4 < full_bytes,
9611            "a 1-in-20 predicate slice fetched {pred_bytes} B and one subject \
9612             {subj_bytes} B where the whole graph fetched {full_bytes} B"
9613        );
9614
9615        // And the plan predicted the index side of it without fetching a tile.
9616        let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
9617        let lazy = Rete::open_ranged_lazy(reader.clone()).unwrap();
9618        let before = reader.bytes_read();
9619        let plan = lazy
9620            .dump_plan(None, None, Some("<http://ex/p/07>"), None)
9621            .scan
9622            .expect("a known predicate plans a scan");
9623        assert!(
9624            reader.bytes_read() - before < 64 * 1024,
9625            "planning fetched {} B — it is supposed to read directories, not tiles",
9626            reader.bytes_read() - before
9627        );
9628        assert!(plan.tiles_admitted < plan.tiles_total);
9629        // The index side on its own, with the dictionary's share taken out: one
9630        // predicate of twenty routes to a handful of POS tiles. (Measured here:
9631        // the whole dump faults 15,714 B, the predicate slice 1,565 B and one
9632        // subject 864 B — a 10x and an 18x, most of what is left being the
9633        // dictionary this change does not prune.)
9634        assert!(
9635            plan.tile_bytes * 5 < plan.section_bytes,
9636            "one predicate of twenty planned {} B of a {} B section",
9637            plan.tile_bytes,
9638            plan.section_bytes
9639        );
9640    }
9641
9642    #[test]
9643    fn query_quads_tags_every_graph() {
9644        let mut db = DictionaryBuilder::new();
9645        for (s, p, o) in [("Alice", "knows", "Bob"), ("Alice", "knows", "Dave")] {
9646            db.observe(s, p, o);
9647        }
9648        let dict = db.build();
9649        let mut def = GraphIndexBuilder::new();
9650        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
9651        let mut g1 = GraphIndexBuilder::new();
9652        g1.push(dict.encode("Alice", "knows", "Dave").unwrap());
9653        let named = vec![("http://ex/g1".to_string(), g1.build())];
9654        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
9655        let rete = Rete::open(&bytes).unwrap();
9656
9657        // `Alice knows ?` spans both graphs; each match carries its graph tag.
9658        let quads = rete.query_quads(Some("Alice"), Some("knows"), None);
9659        assert_eq!(quads.len(), 2);
9660        assert_eq!(
9661            quads[0],
9662            (("Alice".into(), "knows".into(), "Bob".into()), None)
9663        );
9664        assert_eq!(
9665            quads[1],
9666            (
9667                ("Alice".into(), "knows".into(), "Dave".into()),
9668                Some("http://ex/g1".to_string())
9669            )
9670        );
9671
9672        // A bound term absent from the dictionary yields nothing, in any graph.
9673        assert!(rete.query_quads(Some("Nobody"), None, None).is_empty());
9674    }
9675
9676    #[test]
9677    fn pyramid_meta_round_trips_in_file() {
9678        let rete = Rete::open(&build_image()).unwrap();
9679        let pyr = rete.pyramid().expect("file has a pyramid");
9680        // Summary covers all 3 triples by count; tiles are not stored in v0.
9681        let total: u32 = pyr.summary.iter().map(|e| e.count).sum();
9682        assert_eq!(total, 3);
9683        assert!(!pyr.summary.is_empty());
9684        assert!(pyr.tiles.is_empty());
9685    }
9686
9687    #[test]
9688    fn schema_pyramid_round_trips_through_file_index_free() {
9689        use crate::reader::{CountingReader, SliceReader};
9690        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
9691        let q = |s: &str, p: &str, o: &str| {
9692            (s.to_string(), p.to_string(), o.to_string(), None::<String>)
9693        };
9694        // Astronomer ⊑ Scientist ⊑ Person ⊑ Agent, instances at the leaves.
9695        let quads = vec![
9696            q("<a>", RDF_TYPE, "<Astronomer>"),
9697            q("<b>", RDF_TYPE, "<Astronomer>"),
9698            q("<c>", RDF_TYPE, "<Person>"),
9699            q("<Astronomer>", sub, "<Scientist>"),
9700            q("<Scientist>", sub, "<Person>"),
9701            q("<Person>", sub, "<Agent>"),
9702            q("<a>", "<knows>", "<b>"),
9703            q("<b>", "<knows>", "<c>"),
9704        ];
9705        let (bytes, _) =
9706            crate::ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| Vec::new());
9707
9708        // The v2 schema pyramid round-trips through the built file.
9709        let rete = Rete::open(&bytes).unwrap();
9710        let pyr = rete.pyramid().expect("pyramid present");
9711        assert!(!pyr.level_rollups.is_empty(), "schema pyramid shipped");
9712        assert!(pyr
9713            .class_hierarchy
9714            .iter()
9715            .any(|n| n.class == "<Agent>" && n.depth == 0));
9716
9717        // It reads index-free: a SummaryView open never touches the index section.
9718        let r = CountingReader::new(SliceReader::new(&bytes));
9719        let view = SummaryView::open_ranged(&r).unwrap().unwrap();
9720        assert!(view.level_count() >= 2, "multi-level pyramid");
9721        let coarse = view.level_rollup(0).unwrap();
9722        assert!(
9723            coarse.classes.iter().any(|(c, _)| c == "<Agent>"),
9724            "coarsest level rolls up to the root Agent"
9725        );
9726        let h = Header::from_bytes(&bytes).unwrap();
9727        assert!(
9728            r.bytes_read() <= bytes.len() as u64 - h.root_dir_len,
9729            "summary read {} bytes; the {}-byte index section must be skipped",
9730            r.bytes_read(),
9731            h.root_dir_len
9732        );
9733    }
9734
9735    #[test]
9736    fn predicate_totals_from_summary_only() {
9737        use crate::reader::SliceReader;
9738        // build_image: 2 `knows` triples + 1 `age` triple.
9739        let bytes = build_image();
9740        let reader = SliceReader::new(&bytes);
9741        let view = SummaryView::open_ranged(&reader).unwrap().unwrap();
9742        assert_eq!(view.predicate_total("knows"), 2);
9743        assert_eq!(view.predicate_total("age"), 1);
9744        assert_eq!(view.predicate_total("missing"), 0);
9745        let totals = view.predicate_totals();
9746        assert_eq!(totals[0], ("knows".to_string(), 2)); // sorted by count desc
9747    }
9748
9749    #[test]
9750    fn query_patterns_resolve_to_terms() {
9751        let rete = Rete::open(&build_image()).unwrap();
9752
9753        // All triples.
9754        assert_eq!(rete.query(None, None, None).len(), 3);
9755
9756        // Subject bound.
9757        let mut alice = rete.query(Some("Alice"), None, None);
9758        alice.sort();
9759        assert_eq!(
9760            alice,
9761            vec![
9762                ("Alice".into(), "age".into(), "30".into()),
9763                ("Alice".into(), "knows".into(), "Bob".into()),
9764            ]
9765        );
9766
9767        // Predicate bound.
9768        assert_eq!(rete.query(None, Some("knows"), None).len(), 2);
9769
9770        // Full triple, present and absent.
9771        assert_eq!(
9772            rete.query(Some("Bob"), Some("knows"), Some("Carol")),
9773            vec![("Bob".into(), "knows".into(), "Carol".into())]
9774        );
9775        assert!(rete.query(Some("Nobody"), None, None).is_empty());
9776        assert!(rete.query(None, Some("likes"), None).is_empty());
9777    }
9778
9779    #[test]
9780    fn query_provenance_reports_terms_ids_sections_and_index_choice() {
9781        let bytes = build_image();
9782        let rete = Rete::open(&bytes).unwrap();
9783
9784        let mut matches = rete.query_with_provenance(None, Some("knows"), None);
9785        matches.sort_by(|a, b| a.terms.cmp(&b.terms));
9786
9787        assert_eq!(matches.len(), 2);
9788        assert_eq!(
9789            matches[0].terms,
9790            ("Alice".into(), "knows".into(), "Bob".into())
9791        );
9792        assert_eq!(
9793            matches[0].ids,
9794            rete.dictionary().encode("Alice", "knows", "Bob").unwrap()
9795        );
9796        assert_eq!(matches[0].graph.as_deref(), None);
9797        assert_eq!(
9798            matches[0].matched_pattern,
9799            (None, Some(matches[0].ids.1), None)
9800        );
9801        assert_eq!(
9802            matches[0].index_permutation,
9803            crate::index::IndexPermutation::Pos
9804        );
9805
9806        let h = rete.header();
9807        assert_eq!(matches[0].dictionary_range.offset, h.dictionary_offset);
9808        assert_eq!(matches[0].dictionary_range.len, h.dictionary_len);
9809        assert_eq!(matches[0].index_range.offset, h.root_dir_offset);
9810        assert_eq!(matches[0].index_range.len, h.root_dir_len);
9811        assert!(
9812            matches[0].index_section_range.offset > h.root_dir_offset,
9813            "POS is section 1, so its payload starts after the container header and SPO payload"
9814        );
9815        assert!(matches[0].index_section_range.len > 0);
9816        assert!(matches[0].index_section_range.end() <= matches[0].index_range.end());
9817        assert!(matches[0].index_section_range.len < matches[0].index_range.len);
9818        assert_eq!(
9819            matches[0].pyramid_range.as_ref().map(|r| (r.offset, r.len)),
9820            Some((h.pyramid_meta_offset, h.pyramid_meta_len))
9821        );
9822        // Tiled (v0.2) files report the physical tile holding the match; its
9823        // compressed byte range nests inside the selected section payload.
9824        let tile_range = matches[0].tile_range.expect("tiled file reports a tile");
9825        assert!(matches[0]
9826            .tile
9827            .as_deref()
9828            .unwrap()
9829            .starts_with(matches[0].index_permutation.name()));
9830        assert!(matches[0].index_section_range.offset <= tile_range.offset);
9831        assert!(tile_range.end() <= matches[0].index_section_range.end());
9832    }
9833
9834    /// Build an in-memory `.rete` with `n` labeled subjects: each carries an
9835    /// `rdfs:label` literal drawn from `WORDS` (a word prefix selects ~1/|WORDS|
9836    /// of them) plus one extra edge so the subject has a degree to rank by.
9837    fn build_labeled(n: usize) -> Vec<u8> {
9838        const LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
9839        const WORDS: &[&str] = &[
9840            "alanine",
9841            "benzene",
9842            "glucose",
9843            "dextrose",
9844            "ethanol",
9845            "formate",
9846            "heptane",
9847            "isoleucine",
9848        ];
9849        let triples: Vec<(String, String, String)> = (0..n)
9850            .flat_map(|i| {
9851                let s = format!("<http://ex/e{i}>");
9852                let w = WORDS[i % WORDS.len()];
9853                [
9854                    (s.clone(), LABEL.to_string(), format!("\"{w}-{i:06}\"")),
9855                    (
9856                        s,
9857                        "<http://ex/p>".to_string(),
9858                        format!("<http://ex/c{}>", i % 64),
9859                    ),
9860                ]
9861            })
9862            .collect();
9863        let mut db = DictionaryBuilder::new();
9864        for (s, p, o) in &triples {
9865            db.observe(s, p, o);
9866        }
9867        let dict = db.build();
9868        let ids: Vec<(u32, u32, u32)> = triples
9869            .iter()
9870            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
9871            .collect();
9872        let mut ib = GraphIndexBuilder::new();
9873        for &t in &ids {
9874            ib.push(t);
9875        }
9876        let (meta, levels) = build_pyramid_meta(&dict, &ids, DEFAULT_TILE_BUDGET);
9877        write_file(&dict, &ib.build(), false, &meta, levels)
9878    }
9879
9880    #[test]
9881    fn prefix_search_matches_a_filter_scan() {
9882        // 800 < the 8192 label-index cap, so the index is COMPLETE — every label
9883        // is present and the two paths must return the exact same subject set.
9884        let bytes = build_labeled(800);
9885        let rete = Rete::open(&bytes).unwrap();
9886        let idx_subjects: std::collections::BTreeSet<String> = rete
9887            .prefix_search("glucose", 10_000)
9888            .into_iter()
9889            .map(|(_label, subject)| subject)
9890            .collect();
9891        // The same selection via a SPARQL FILTER scan over every label literal.
9892        let q = "SELECT ?s WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?l \
9893                 FILTER(STRSTARTS(LCASE(?l), \"glucose\")) }";
9894        let crate::QueryOutput::Select(_, rows) = crate::eval_query(&rete, q).unwrap() else {
9895            panic!("expected SELECT");
9896        };
9897        let scan_subjects: std::collections::BTreeSet<String> =
9898            rows.iter().map(|r| r.get("s").cloned().unwrap()).collect();
9899        assert_eq!(idx_subjects, scan_subjects, "index agrees with the scan");
9900        assert_eq!(
9901            idx_subjects.len(),
9902            100,
9903            "800/8 words = 100 glucose-* labels"
9904        );
9905    }
9906
9907    /// A legacy (pyramid-block) label search resolves its hits' subjects in one
9908    /// batched dictionary read on a lazy open — the label-search twin of the
9909    /// full-text hits' batch — instead of one dependent read per chunk.
9910    #[test]
9911    fn legacy_label_hits_resolve_in_one_batched_read() {
9912        use std::sync::{Arc, Mutex};
9913        type CallLog = Arc<Mutex<Vec<Vec<(u64, u64)>>>>;
9914        #[derive(Clone)]
9915        struct Calls(Arc<Vec<u8>>, CallLog);
9916        impl crate::RangeReader for Calls {
9917            fn len(&self) -> u64 {
9918                self.0.len() as u64
9919            }
9920            fn read_at(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
9921                self.1.lock().unwrap().push(vec![(offset, len)]);
9922                crate::SliceReader::new(&self.0).read_at(offset, len)
9923            }
9924            fn read_many(&self, ranges: &[(u64, u64)]) -> std::io::Result<Vec<Vec<u8>>> {
9925                self.1.lock().unwrap().push(ranges.to_vec());
9926                let r = crate::SliceReader::new(&self.0);
9927                ranges.iter().map(|&(o, l)| r.read_at(o, l)).collect()
9928            }
9929        }
9930        let bytes = build_labeled(20_000);
9931        let h = Header::from_bytes(&bytes).unwrap();
9932        let (d0, dl) = (h.dictionary_offset, h.dictionary_len);
9933        let want = Rete::open(&bytes).unwrap().prefix_search("glucose", 1000);
9934        assert_eq!(want.len(), 1000);
9935        for search_view in [false, true] {
9936            let calls = Calls(Arc::new(bytes.clone()), Arc::new(Mutex::new(Vec::new())));
9937            let got = if search_view {
9938                let view = SearchView::open_ranged(calls.clone()).unwrap();
9939                calls.1.lock().unwrap().clear();
9940                view.prefix_search("glucose", 1000)
9941            } else {
9942                let rete = Rete::open_ranged_lazy(calls.clone()).unwrap();
9943                calls.1.lock().unwrap().clear();
9944                rete.prefix_search("glucose", 1000)
9945            };
9946            assert_eq!(got, want, "search view = {search_view}");
9947            let dict_reads = calls
9948                .1
9949                .lock()
9950                .unwrap()
9951                .iter()
9952                .filter(|c| c.iter().any(|&(o, l)| o < d0 + dl && d0 < o + l))
9953                .count();
9954            assert_eq!(dict_reads, 1, "search view = {search_view}");
9955        }
9956    }
9957
9958    /// Latency: the binary-search label index vs the FILTER scan it replaces.
9959    /// Ignored by default (timing-sensitive); run with
9960    /// `cargo test -p rete-core -- --ignored --nocapture bench_prefix_search`.
9961    #[test]
9962    #[ignore]
9963    fn bench_prefix_search_vs_filter_scan() {
9964        use std::time::Instant;
9965        let n = 6000; // < the 8192 cap, so both paths return identical sets
9966        let bytes = build_labeled(n);
9967        let rete = Rete::open(&bytes).unwrap();
9968        let q = "SELECT ?s WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?l \
9969                 FILTER(STRSTARTS(LCASE(?l), \"glucose\")) }";
9970        let reps = 200;
9971        let idx_n = rete.prefix_search("glucose", 100_000).len();
9972        let t = Instant::now();
9973        for _ in 0..reps {
9974            std::hint::black_box(rete.prefix_search("glucose", 100_000));
9975        }
9976        let idx_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
9977        let t = Instant::now();
9978        for _ in 0..reps {
9979            let _ = std::hint::black_box(crate::eval_query(&rete, q).unwrap());
9980        }
9981        let scan_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
9982        println!(
9983            "label prefix search over {n} labeled subjects ({idx_n} matches): \
9984             index {idx_ms:.4} ms vs FILTER scan {scan_ms:.3} ms ({:.0}× faster)",
9985            scan_ms / idx_ms
9986        );
9987    }
9988
9989    /// Latency: the TEXT_INDEX word search vs the `FILTER(CONTAINS(?l, …))` scan
9990    /// it replaces. Ignored by default (timing-sensitive); run with
9991    /// `cargo test -p rete-core -- --ignored --nocapture bench_text_search`.
9992    #[test]
9993    #[ignore]
9994    fn bench_text_search_vs_contains_scan() {
9995        use std::time::Instant;
9996        const LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
9997        const WORDS: &[&str] = &[
9998            "alanine",
9999            "benzene",
10000            "glucose",
10001            "dextrose",
10002            "ethanol",
10003            "formate",
10004            "heptane",
10005            "isoleucine",
10006        ];
10007        let n = 6000;
10008        let triples: Vec<(String, String, String)> = (0..n)
10009            .map(|i| {
10010                (
10011                    format!("<http://ex/e{i}>"),
10012                    LABEL.to_string(),
10013                    format!("\"{} sample number {i:06}\"", WORDS[i % WORDS.len()]),
10014                )
10015            })
10016            .collect();
10017        let mut db = DictionaryBuilder::new();
10018        for (s, p, o) in &triples {
10019            db.observe(s, p, o);
10020        }
10021        let dict = db.build();
10022        let ids: Vec<(u32, u32, u32)> = triples
10023            .iter()
10024            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
10025            .collect();
10026        let mut ib = GraphIndexBuilder::new();
10027        for &t in &ids {
10028            ib.push(t);
10029        }
10030        let ti = compute_text_index(&dict, &ids);
10031        let bytes = write_dataset_with_metadata(&dict, &ib.build(), &[], false, &[], 0, &[], &ti);
10032        let rete = Rete::open(&bytes).unwrap();
10033
10034        let q = "SELECT ?s WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?l \
10035                 FILTER(CONTAINS(LCASE(?l), \"glucose\")) }";
10036        let reps = 200;
10037        let idx_n = rete.text_search(&["glucose"], None, 100_000).len();
10038        let t = Instant::now();
10039        for _ in 0..reps {
10040            std::hint::black_box(rete.text_search(&["glucose"], None, 100_000));
10041        }
10042        let idx_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
10043        let t = Instant::now();
10044        for _ in 0..reps {
10045            let _ = std::hint::black_box(crate::eval_query(&rete, q).unwrap());
10046        }
10047        let scan_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
10048        println!(
10049            "text search over {n} literals ({idx_n} matches): \
10050             index {idx_ms:.4} ms vs FILTER(CONTAINS) scan {scan_ms:.3} ms ({:.0}× faster)",
10051            scan_ms / idx_ms
10052        );
10053    }
10054
10055    /// Operational debugging harness for a REAL on-disk file (ignored; driven
10056    /// by env vars): step-by-step dump of a bound (p, o) POS routing.
10057    ///   RETE_DEBUG_FILE=<path.rete> RETE_DEBUG_P=<iri> RETE_DEBUG_O=<iri>
10058    #[test]
10059    #[ignore = "operational tool, driven by RETE_DEBUG_* env vars"]
10060    fn debug_bound_po_routing() {
10061        struct FR(std::fs::File);
10062        impl crate::RangeReader for FR {
10063            fn len(&self) -> u64 {
10064                self.0.metadata().map(|m| m.len()).unwrap_or(0)
10065            }
10066            fn read_at(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
10067                use std::os::unix::fs::FileExt;
10068                let mut buf = vec![0u8; len as usize];
10069                self.0.read_exact_at(&mut buf, offset)?;
10070                Ok(buf)
10071            }
10072        }
10073        let path = std::env::var("RETE_DEBUG_FILE").expect("RETE_DEBUG_FILE");
10074        let p_iri = std::env::var("RETE_DEBUG_P").expect("RETE_DEBUG_P");
10075        let o_iri = std::env::var("RETE_DEBUG_O").expect("RETE_DEBUG_O");
10076        let rete =
10077            Rete::open_ranged_lazy(std::sync::Arc::new(FR(std::fs::File::open(&path).unwrap())))
10078                .unwrap();
10079        let pid = rete.dict.predicate_id(&p_iri).expect("p resolves");
10080        let oid = rete.dict.object_id(&o_iri).expect("o resolves");
10081        eprintln!("pid={pid} oid={oid}");
10082        let pattern = (None, Some(pid), Some(oid));
10083        let perm = GraphIndex::best_permutation(pattern);
10084        eprintln!("best_permutation = {}", perm.name());
10085        let si = perm.section_index();
10086        let tiles = &rete.index.sections[si];
10087        eprintln!("section {} tiles = {}", perm.name(), tiles.len());
10088        let [pa, pb, pc] = perm.order_pattern(pattern);
10089        eprintln!("permuted pattern pa={pa:?} pb={pb:?} pc={pc:?}");
10090        let (start, end) = rete.index.tile_span(si, pa);
10091        eprintln!("tile_span = [{start}, {end}) -> {} tiles", end - start);
10092        let mut admitted = 0usize;
10093        for (ti, t) in tiles.iter().enumerate().take(end).skip(start) {
10094            if t.syn_admits(pb, pc) {
10095                admitted += 1;
10096                if admitted <= 10 {
10097                    let (lo, hi) = t.leading_range();
10098                    eprintln!("  admit tile {ti}: a=[{lo},{hi}] syn={:?}", t.syn);
10099                }
10100            }
10101        }
10102        eprintln!("admitted {admitted} tile(s) by synopsis");
10103        let n = rete.index.scan_iter(pattern).count();
10104        eprintln!("scan_iter matches = {n}");
10105        let hi_res = rete.query(None, Some(&p_iri), Some(&o_iri));
10106        eprintln!("high-level query matches = {}", hi_res.len());
10107    }
10108
10109    /// The header segment's LABEL and the span it describes must agree. They
10110    /// did not from v0.3 (which grew the header to 1 KiB) until this test: the
10111    /// label still said "fixed 128 bytes", the pre-v0.3 size, while the segment
10112    /// covered `HEADER_LEN` = 1024 — and that label is exactly what the file
10113    /// explorer shows someone inspecting a real file. Reading the size back out
10114    /// of the string is the point: it fails if anyone retypes the number
10115    /// instead of deriving it from `HEADER_LEN`.
10116    #[test]
10117    fn layout_header_label_matches_its_span() {
10118        let mut db = DictionaryBuilder::new();
10119        let mut triples = Vec::new();
10120        for i in 0..24u32 {
10121            let (s, p, o) = (
10122                format!("<http://ex/s{}>", i % 5),
10123                format!("<http://ex/p{}>", i % 3),
10124                format!("<http://ex/o{i}>"),
10125            );
10126            db.observe(&s, &p, &o);
10127            triples.push((s, p, o));
10128        }
10129        let dict = db.build();
10130        let ids: Vec<(u32, u32, u32)> = triples
10131            .iter()
10132            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
10133            .collect();
10134        let index = GraphIndexBuilder::from_triples(ids).build();
10135        let image = write_dataset_with_metadata(
10136            &dict,
10137            &index,
10138            &[],
10139            false,
10140            &[],
10141            0,
10142            br#"{"name":"layout-label-fixture"}"#,
10143            &[],
10144        );
10145        let rete = Rete::open(&image).unwrap();
10146
10147        let layout = rete.file_layout();
10148        let header = layout
10149            .iter()
10150            .find(|s| s.kind == "header")
10151            .expect("every layout starts with a header segment");
10152
10153        assert_eq!(
10154            header.offset, 0,
10155            "the header is the first thing in the file"
10156        );
10157        assert_eq!(
10158            header.len, HEADER_LEN as u64,
10159            "the header segment must span exactly HEADER_LEN"
10160        );
10161
10162        // Pull the number back out of the human-readable label and hold it to
10163        // the span. A hard-coded literal that drifts from HEADER_LEN dies here.
10164        let digits: String = header
10165            .label
10166            .chars()
10167            .skip_while(|c| !c.is_ascii_digit())
10168            .take_while(|c| c.is_ascii_digit())
10169            .collect();
10170        assert!(
10171            !digits.is_empty(),
10172            "header label {:?} states no size at all",
10173            header.label
10174        );
10175        assert_eq!(
10176            digits.parse::<u64>().unwrap(),
10177            header.len,
10178            "header label {:?} disagrees with the {}-byte span it describes",
10179            header.label,
10180            header.len
10181        );
10182
10183        // The layout must also stay sorted and start at byte zero, which is
10184        // what makes "the first segment is the header" meaningful.
10185        assert!(
10186            layout.windows(2).all(|w| w[0].offset <= w[1].offset),
10187            "file_layout must return segments sorted by offset"
10188        );
10189    }
10190}