Skip to main content

rete_core/
index.rs

1//! Permutation index set (SPO / POS / OSP) over integer triples (SPEC.md §6).
2//!
3//! The three orders together answer every triple-pattern shape: for any subset
4//! of bound `(s, p, o)` components, at least one permutation sorts those bound
5//! components into a leading prefix, turning the lookup into a range scan.
6//!
7//! Each permutation is **tiled** (format v0.2): consecutive runs of whole
8//! a-groups packed to a byte budget ([`INDEX_TILE_BUDGET`]), each tile a
9//! self-contained [`TripleBlock`]. A bound leading id routes to exactly one
10//! tile (binary search over the tile ranges), then jumps to its a-group via
11//! the tile's lazily-built [`GroupDirectory`]; unbound scans chain all tiles
12//! with per-tile zone-map pruning. On disk each tile is compressed
13//! independently, so a ranged reader can fetch just the tiles a query needs.
14//! v0.1 single-block sections are still read (one tile per permutation).
15
16use std::sync::OnceLock;
17
18use crate::triples::{GroupDirectory, Triple, TripleBlock, TripleBlockBuilder};
19
20/// A triple pattern: `None` is an unbound variable, `Some(id)` a bound term.
21pub type Pattern = (Option<u32>, Option<u32>, Option<u32>);
22
23/// Default per-tile budget in (uncompressed) encoded bytes. Tiles are the
24/// independently-compressed, independently-fetchable units of a permutation
25/// section: ~64 KiB matches both zstd's sweet spot and one HTTP range read.
26pub const INDEX_TILE_BUDGET: usize = 64 * 1024;
27
28/// Geometric tile-prefetch ramp for an unbound (multi-tile) scan (see
29/// [`GraphIndex::scan_iter`]). The first coalesced batch faults this many
30/// tiles; each subsequent batch doubles up to [`PREFETCH_WINDOW_MAX`]. Small
31/// enough that `LIMIT 1` fetches only a few tiles, large enough that a full
32/// scan still coalesces into a handful of range reads.
33const PREFETCH_WINDOW_START: usize = 4;
34const PREFETCH_WINDOW_MAX: usize = 512;
35
36/// Fetches one tile's (uncompressed) block image on demand: the bridge that
37/// lets a remote `GraphIndex` fault tiles in over a `RangeReader` without this
38/// module knowing about I/O. `None` = the fetch failed; the index records the
39/// failure ([`GraphIndex::load_incomplete`]) and the scan sees an empty tile —
40/// callers running over remote data MUST check the flag after evaluating.
41pub type TileLoader = Box<dyn Fn(usize, usize) -> Option<Vec<u8>> + Send + Sync>;
42
43/// Fetches **many** tiles of one section in one round trip: given the section
44/// and the (ascending) indices of the tiles wanted, returns each tile's
45/// uncompressed block image in the same order. The ranged reader implements
46/// this by coalescing byte-adjacent tile ranges into single range reads, so a
47/// full-section scan costs a handful of requests instead of one per tile.
48/// `None` = the batch failed as a whole; callers fall back to the per-tile
49/// [`TileLoader`] (which records per-tile failures).
50pub type TileBulkLoader = Box<dyn Fn(usize, &[usize]) -> Option<Vec<Vec<u8>>> + Send + Sync>;
51
52/// One tile of a permutation section: a fully self-contained [`TripleBlock`]
53/// over a consecutive run of whole a-groups, plus the leading-id range it
54/// covers (for routing without parsing or fetching) and its lazily-built group
55/// directory (for intra-tile point lookups). The block image itself may be
56/// local (built/decoded eagerly) or faulted in by the index's [`TileLoader`].
57pub struct Tile {
58    min_a: u32,
59    max_a: u32,
60    /// Optional **tile synopsis**: the inclusive min/max of the two non-leading
61    /// columns `(min_b, max_b, min_c, max_c)`, read from the directory trailer of
62    /// a synopsis-carrying file. Lets a scan prune this tile by a bound secondary
63    /// component *before* faulting it (remote reads). `None` = no synopsis (an
64    /// older file, or a locally-built/opened tile) — then nothing is pruned early
65    /// and the in-tile zone map prunes after the tile is in hand, as before.
66    pub(crate) syn: Option<(u32, u32, u32, u32)>,
67    /// Encoded byte length of this tile (compressed on-disk size for a remote
68    /// tile, in-memory image size for a local one; 0 = unknown). Feeds the join
69    /// planner's fatness gates without faulting any data.
70    len: u32,
71    data: OnceLock<Vec<u8>>,
72    dir: OnceLock<GroupDirectory>,
73}
74
75impl Tile {
76    fn local(min_a: u32, max_a: u32, bytes: Vec<u8>) -> Self {
77        let len = bytes.len().min(u32::MAX as usize) as u32;
78        let data = OnceLock::new();
79        let _ = data.set(bytes);
80        Tile {
81            min_a,
82            max_a,
83            syn: None,
84            len,
85            data,
86            dir: OnceLock::new(),
87        }
88    }
89
90    fn remote(min_a: u32, max_a: u32, syn: Option<(u32, u32, u32, u32)>) -> Self {
91        Tile {
92            min_a,
93            max_a,
94            syn,
95            len: 0,
96            data: OnceLock::new(),
97            dir: OnceLock::new(),
98        }
99    }
100
101    /// Encoded byte length (see the field doc); falls back to the loaded image
102    /// size when the directory didn't provide one.
103    pub(crate) fn encoded_len(&self) -> u64 {
104        if self.len > 0 {
105            self.len as u64
106        } else {
107            self.data.get().map_or(0, |d| d.len() as u64)
108        }
109    }
110
111    /// Leading-component (permuted `a`) range this tile covers, inclusive.
112    pub fn leading_range(&self) -> (u32, u32) {
113        (self.min_a, self.max_a)
114    }
115
116    /// Could this tile hold a triple with the given bound non-leading components,
117    /// per its synopsis? `true` when there is no synopsis (can't rule it out) or
118    /// the bound `b`/`c` fall inside the recorded ranges. Conservative: a `false`
119    /// is a *proven* miss (the in-tile zone map would reject the same tile), so
120    /// skipping the fetch never drops a result.
121    pub(crate) fn syn_admits(&self, pb: Option<u32>, pc: Option<u32>) -> bool {
122        match self.syn {
123            None => true,
124            Some((min_b, max_b, min_c, max_c)) => {
125                let ok = |v: Option<u32>, lo: u32, hi: u32| v.is_none_or(|x| lo <= x && x <= hi);
126                ok(pb, min_b, max_b) && ok(pc, min_c, max_c)
127            }
128        }
129    }
130
131    /// The tile's serialized (uncompressed) [`TripleBlock`] image, if present
132    /// locally (always, for built/opened indexes; empty for an unfaulted
133    /// remote tile — the writer never sees those).
134    pub fn bytes(&self) -> &[u8] {
135        self.data.get().map(Vec::as_slice).unwrap_or(&[])
136    }
137}
138
139/// Which stored permutation to scan. The full **six** orders (a SPARQL engine's
140/// classic set, as in QLever): together they sort the triples on *every* prefix
141/// of `(s, p, o)` columns, so for any bound-component prefix and any free
142/// component there is a permutation that routes on the prefix **and** yields the
143/// stream sorted on that free component — the precondition for a merge join.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum IndexPermutation {
146    Spo,
147    Sop,
148    Pso,
149    Pos,
150    Osp,
151    Ops,
152}
153
154/// The number of stored permutations.
155pub(crate) const NUM_PERMS: usize = 6;
156
157/// The six permutations in section order — the canonical iteration list. The
158/// original three (SPO, POS, OSP) lead, so a default scan's tie-break (and its
159/// provenance) is unchanged from the 3-permutation format; the three added orders
160/// (SOP, PSO, OPS) follow and exist to give a merge join a stream sorted on the
161/// join key.
162pub(crate) const ALL_PERMS: [IndexPermutation; NUM_PERMS] = [
163    IndexPermutation::Spo,
164    IndexPermutation::Pos,
165    IndexPermutation::Osp,
166    IndexPermutation::Sop,
167    IndexPermutation::Pso,
168    IndexPermutation::Ops,
169];
170
171impl IndexPermutation {
172    /// Stable display name used in CLI diagnostics and provenance records.
173    pub fn name(self) -> &'static str {
174        match self {
175            IndexPermutation::Spo => "SPO",
176            IndexPermutation::Sop => "SOP",
177            IndexPermutation::Pso => "PSO",
178            IndexPermutation::Pos => "POS",
179            IndexPermutation::Osp => "OSP",
180            IndexPermutation::Ops => "OPS",
181        }
182    }
183
184    /// Section number inside the stored permutation container.
185    pub fn section_index(self) -> usize {
186        match self {
187            IndexPermutation::Spo => 0,
188            IndexPermutation::Pos => 1,
189            IndexPermutation::Osp => 2,
190            IndexPermutation::Sop => 3,
191            IndexPermutation::Pso => 4,
192            IndexPermutation::Ops => 5,
193        }
194    }
195
196    /// The canonical `(s, p, o)` roles in this permutation's `(a, b, c)` slots,
197    /// as indices (0=s, 1=p, 2=o). The single source of truth for `forward` /
198    /// `back` / `order_pattern`.
199    pub(crate) const fn roles(self) -> [usize; 3] {
200        match self {
201            IndexPermutation::Spo => [0, 1, 2],
202            IndexPermutation::Sop => [0, 2, 1],
203            IndexPermutation::Pso => [1, 0, 2],
204            IndexPermutation::Pos => [1, 2, 0],
205            IndexPermutation::Osp => [2, 0, 1],
206            IndexPermutation::Ops => [2, 1, 0],
207        }
208    }
209
210    /// Map a canonical `(s, p, o)` triple into this permutation's `(a, b, c)`.
211    pub(crate) fn forward(self, t: Triple) -> Triple {
212        let c = [t.0, t.1, t.2];
213        let r = self.roles();
214        (c[r[0]], c[r[1]], c[r[2]])
215    }
216
217    /// Map a stored `(a, b, c)` back to canonical `(s, p, o)`.
218    fn back(self, abc: Triple) -> Triple {
219        let a = [abc.0, abc.1, abc.2];
220        let r = self.roles();
221        let mut out = [0u32; 3];
222        out[r[0]] = a[0];
223        out[r[1]] = a[1];
224        out[r[2]] = a[2];
225        (out[0], out[1], out[2])
226    }
227
228    /// The pattern's bound components in this permutation's component order.
229    pub(crate) fn order_pattern(self, p: Pattern) -> [Option<u32>; 3] {
230        let c = [p.0, p.1, p.2];
231        let r = self.roles();
232        [c[r[0]], c[r[1]], c[r[2]]]
233    }
234
235    /// Length of the leading run of bound components — higher is more selective.
236    fn leading_bound(self, pat: Pattern) -> usize {
237        self.order_pattern(pat)
238            .iter()
239            .take_while(|c| c.is_some())
240            .count()
241    }
242}
243
244/// Build a [`GraphIndex`] from canonical `(s, p, o)` integer triples.
245pub struct GraphIndexBuilder {
246    triples: Vec<Triple>,
247    tile_budget: usize,
248}
249
250impl Default for GraphIndexBuilder {
251    fn default() -> Self {
252        Self {
253            triples: Vec::new(),
254            tile_budget: INDEX_TILE_BUDGET,
255        }
256    }
257}
258
259impl GraphIndexBuilder {
260    pub fn new() -> Self {
261        Self::default()
262    }
263
264    /// Build directly from an owned triple vector — moves it in, avoiding the
265    /// per-triple `push` copy when the caller already has the id-triples in a
266    /// `Vec` (the assembly path, where they double as the pyramid input).
267    pub fn from_triples(triples: Vec<Triple>) -> Self {
268        Self {
269            triples,
270            tile_budget: INDEX_TILE_BUDGET,
271        }
272    }
273
274    /// Override the per-tile byte budget (tests use tiny budgets to force
275    /// multi-tile sections on small data).
276    pub fn with_tile_budget(mut self, bytes: usize) -> Self {
277        self.tile_budget = bytes.max(1);
278        self
279    }
280
281    pub fn push(&mut self, t: Triple) {
282        self.triples.push(t);
283    }
284
285    /// Build the six permutations **one at a time** (each permutation's internal
286    /// sort still uses every core), so only a single permuted copy of the triples
287    /// is resident at once instead of all six concurrently. Trades the cross-
288    /// permutation parallelism of [`Self::build`] for a much lower peak RAM — the
289    /// large-graph low-memory path. Output is byte-identical to [`Self::build`].
290    pub fn build_seq(self) -> GraphIndex {
291        let triples = &self.triples;
292        let budget = self.tile_budget;
293        // Permute (parallel) + sort (parallel inside `build_tiles`) one permutation
294        // at a time, so only a single permuted copy of the triples is resident — but
295        // every core is still busy. Process permutations in batches of two to
296        // overlap one permutation's tile-building with the next one's sort while
297        // keeping the resident permuted copies to two. Byte-identical to `build`.
298        let build_one = |perm: IndexPermutation| -> Vec<Tile> {
299            #[cfg(feature = "parallel")]
300            let permuted: Vec<Triple> = {
301                use rayon::prelude::*;
302                triples.par_iter().map(|&t| perm.forward(t)).collect()
303            };
304            #[cfg(not(feature = "parallel"))]
305            let permuted: Vec<Triple> = triples.iter().map(|&t| perm.forward(t)).collect();
306            build_tiles(permuted, budget)
307        };
308        #[cfg(feature = "parallel")]
309        let sections: [Vec<Tile>; NUM_PERMS] = {
310            use rayon::prelude::*;
311            let mut built: Vec<Vec<Tile>> = Vec::with_capacity(NUM_PERMS);
312            for chunk in ALL_PERMS.chunks(2) {
313                built.extend(
314                    chunk
315                        .to_vec()
316                        .into_par_iter()
317                        .map(build_one)
318                        .collect::<Vec<_>>(),
319                );
320            }
321            built.try_into().ok().expect("six permutations")
322        };
323        #[cfg(not(feature = "parallel"))]
324        let sections: [Vec<Tile>; NUM_PERMS] = ALL_PERMS.map(build_one);
325        GraphIndex::from_sections(sections)
326    }
327
328    pub fn build(self) -> GraphIndex {
329        let perms = ALL_PERMS;
330        let triples = &self.triples;
331        let budget = self.tile_budget;
332        // The six permutations are independent — permute + sort + tile each.
333        let build_one = move |perm: IndexPermutation| -> Vec<Tile> {
334            let permuted: Vec<Triple> = triples.iter().map(|&t| perm.forward(t)).collect();
335            build_tiles(permuted, budget)
336        };
337        // Build the permutations concurrently when the `parallel` feature is on
338        // (they share no state); the per-permutation sort inside is also
339        // parallel. Output is byte-identical to the serial path.
340        #[cfg(feature = "parallel")]
341        let sections: [Vec<Tile>; NUM_PERMS] = {
342            use rayon::iter::{IntoParallelIterator, ParallelIterator};
343            let built: Vec<Vec<Tile>> = perms.into_par_iter().map(build_one).collect();
344            // `.ok()` drops the (non-Debug) Vec error so `expect` compiles.
345            built.try_into().ok().expect("six permutations")
346        };
347        #[cfg(not(feature = "parallel"))]
348        let sections: [Vec<Tile>; NUM_PERMS] = perms.map(build_one);
349        GraphIndex::from_sections(sections)
350    }
351}
352
353/// The encoded varint length of `v` (LEB128).
354fn varint_len(mut v: u64) -> usize {
355    let mut n = 1;
356    while v >= 0x80 {
357        v >>= 7;
358        n += 1;
359    }
360    n
361}
362
363/// Incremental encoded-size accounting for one a-group of a tiled section —
364/// the running-delta chain of [`build_tiles`], streamable triple by triple.
365/// Shared by the in-memory tiler here and the external build's streaming
366/// tiler ([`crate::extbuild`]) so both choose IDENTICAL tile boundaries and
367/// their outputs stay byte-identical.
368pub(crate) struct GroupSizer {
369    /// finalized contributions: the a-delta plus every closed b-run
370    size: usize,
371    num_b: u64,
372    cur_b: u32,
373    /// the open b-run's c count / last c
374    num_c: u64,
375    prev_c: u32,
376    empty: bool,
377}
378
379impl GroupSizer {
380    /// Start a group for leading id `a` whose delta base is `prev_a` (the
381    /// previous group's leading id; `a` again for a mid-group continuation).
382    pub(crate) fn start(a: u32, prev_a: u32) -> Self {
383        GroupSizer {
384            size: varint_len((a - prev_a) as u64),
385            num_b: 0,
386            cur_b: 0,
387            num_c: 0,
388            prev_c: 0,
389            empty: true,
390        }
391    }
392
393    /// Account one `(b, c)` of this group; returns the group's total encoded
394    /// size so far (open count varints included).
395    pub(crate) fn push(&mut self, b: u32, c: u32) -> usize {
396        if self.empty || b != self.cur_b {
397            if self.empty {
398                self.size += varint_len(b as u64); // first b-run: delta from 0
399            } else {
400                self.size += varint_len(self.num_c); // close the previous b-run
401                self.size += varint_len((b - self.cur_b) as u64);
402            }
403            self.cur_b = b;
404            self.num_c = 0;
405            self.prev_c = 0;
406            self.num_b += 1;
407            self.empty = false;
408        }
409        self.size += varint_len((c - self.prev_c) as u64);
410        self.prev_c = c;
411        self.num_c += 1;
412        self.total()
413    }
414
415    /// The group's total encoded size so far.
416    pub(crate) fn total(&self) -> usize {
417        self.size
418            + if self.empty {
419                0
420            } else {
421                varint_len(self.num_c)
422            }
423            + varint_len(self.num_b)
424    }
425}
426
427/// Split sorted, deduped permuted triples into size-targeted tiles: append
428/// whole a-groups until the (estimated, near-exact) encoded size would exceed
429/// `budget`, then flush — and additionally cut WITHIN an a-group whose own
430/// running size exceeds the budget (a mega-group: one predicate or class
431/// carrying a large share of the graph, e.g. a 2B-triple `cites` predicate in
432/// POS). Split slices become consecutive tiles sharing the leading id — a
433/// bound leading id routes to the whole run of covering tiles (see
434/// [`GraphIndex::tile_span`]) — which bounds both builder memory and the
435/// bytes a remote reader faults for one lookup. Boundary accounting lives in
436/// [`GroupSizer`], shared with the external build for byte-identity.
437fn build_tiles(mut triples: Vec<Triple>, budget: usize) -> Vec<Tile> {
438    #[cfg(feature = "parallel")]
439    {
440        use rayon::slice::ParallelSliceMut;
441        triples.par_sort_unstable();
442    }
443    #[cfg(not(feature = "parallel"))]
444    triples.sort_unstable();
445    triples.dedup();
446    if triples.is_empty() {
447        return Vec::new();
448    }
449
450    let make_tile = |run: &[Triple]| -> Tile {
451        let mut b = TripleBlockBuilder::new();
452        for &t in run {
453            b.push(t);
454        }
455        Tile::local(run[0].0, run[run.len() - 1].0, b.build())
456    };
457
458    let mut tiles = Vec::new();
459    let mut tile_start = 0usize;
460    let mut tile_size = 0usize; // completed groups in the current tile
461    let mut prev_a = 0u32;
462    let mut i = 0usize;
463    while i < triples.len() {
464        let a = triples[i].0;
465        let mut slice_start = i; // group start, or the last mid-group cut
466        let mut sizer = GroupSizer::start(a, prev_a);
467        let mut gtotal = 0usize;
468        while i < triples.len() && triples[i].0 == a {
469            gtotal = sizer.push(triples[i].1, triples[i].2);
470            i += 1;
471            if gtotal > budget {
472                // Mega-group cut: everything buffered — completed groups plus
473                // the slice measured so far — becomes one tile; the group
474                // continues in a fresh chain with `a` as its own delta base.
475                tiles.push(make_tile(&triples[tile_start..i]));
476                tile_start = i;
477                tile_size = 0;
478                prev_a = a;
479                slice_start = i;
480                sizer = GroupSizer::start(a, a);
481                gtotal = 0;
482            }
483        }
484        if slice_start == i {
485            continue; // the cut landed exactly on the group's end
486        }
487        if slice_start > tile_start && tile_size + gtotal > budget {
488            tiles.push(make_tile(&triples[tile_start..slice_start]));
489            tile_start = slice_start;
490            tile_size = 0;
491        }
492        tile_size += gtotal;
493        prev_a = a;
494    }
495    if tile_start < triples.len() {
496        tiles.push(make_tile(&triples[tile_start..]));
497    }
498    tiles
499}
500
501/// The six tiled permutation sections, queryable by triple pattern.
502pub struct GraphIndex {
503    /// Tiles per permutation (SPO, SOP, PSO, POS, OSP, OPS — see [`ALL_PERMS`]),
504    /// ascending in their leading-id ranges; consecutive tiles may share a
505    /// leading id when a mega-group was split (see [`build_tiles`]).
506    pub(crate) sections: [Vec<Tile>; NUM_PERMS],
507    /// Faults in remote tiles on first scan (`None` for local indexes).
508    loader: Option<TileLoader>,
509    /// Optional batched fetch for multi-tile scans (`None` falls back to
510    /// one [`TileLoader`] call per tile).
511    bulk: Option<TileBulkLoader>,
512    /// Set when the loader failed for some tile: results may be incomplete and
513    /// the caller must surface an error rather than the partial answer.
514    load_failed: std::sync::atomic::AtomicBool,
515    /// The reader's concurrent-range fan-out (1 = strictly sequential) — see
516    /// [`set_read_concurrency`](Self::set_read_concurrency).
517    read_concurrency: usize,
518}
519
520impl GraphIndex {
521    fn from_sections(sections: [Vec<Tile>; NUM_PERMS]) -> Self {
522        GraphIndex {
523            sections,
524            loader: None,
525            bulk: None,
526            load_failed: std::sync::atomic::AtomicBool::new(false),
527            read_concurrency: 1,
528        }
529    }
530
531    /// Rebuild from tiled sections: per permutation, `(min_a, max_a, block
532    /// bytes)` per tile in ascending leading-id order — the v0.2 layout.
533    pub fn from_tiles(sections: [Vec<(u32, u32, Vec<u8>)>; NUM_PERMS]) -> Self {
534        let sections = sections.map(|tiles| {
535            tiles
536                .into_iter()
537                .map(|(min_a, max_a, bytes)| Tile::local(min_a, max_a, bytes))
538                .collect()
539        });
540        Self::from_sections(sections)
541    }
542
543    /// A **remote** index: only the tile directories (leading-id ranges per
544    /// permutation, ascending) are known; tile payloads fault in through
545    /// `loader` on first scan. Check [`load_incomplete`](Self::load_incomplete)
546    /// after evaluating — a failed fetch must become an error, never a
547    /// silently smaller result.
548    #[allow(clippy::type_complexity)]
549    pub fn from_remote_directories(
550        directories: [Vec<(u32, u32, Option<(u32, u32, u32, u32)>)>; NUM_PERMS],
551        loader: TileLoader,
552    ) -> Self {
553        let sections = directories.map(|dir| {
554            dir.into_iter()
555                .map(|(min_a, max_a, syn)| Tile::remote(min_a, max_a, syn))
556                .collect()
557        });
558        GraphIndex {
559            sections,
560            loader: Some(loader),
561            bulk: None,
562            load_failed: std::sync::atomic::AtomicBool::new(false),
563            read_concurrency: 1,
564        }
565    }
566
567    /// Attach a batched tile fetcher (see [`TileBulkLoader`]): multi-tile
568    /// scans prefetch their span through it instead of faulting tile by tile.
569    pub fn with_bulk_loader(mut self, bulk: TileBulkLoader) -> Self {
570        self.bulk = Some(bulk);
571        self
572    }
573
574    /// Record each remote tile's encoded (on-disk) byte length, per section —
575    /// known to the ranged opener from the tile directory. Powers the join
576    /// planner's fatness gates; never triggers a fetch.
577    pub(crate) fn set_tile_lens(&mut self, lens: [Vec<u32>; NUM_PERMS]) {
578        for (section, ls) in self.sections.iter_mut().zip(lens) {
579            for (tile, l) in section.iter_mut().zip(ls) {
580                tile.len = l;
581            }
582        }
583    }
584
585    /// Record the reader's concurrent-range fan-out (see
586    /// [`RangeReader::concurrency`](crate::reader::RangeReader::concurrency)) —
587    /// the join planner widens its remote probe budget when round trips
588    /// overlap instead of serializing.
589    pub(crate) fn set_read_concurrency(&mut self, c: usize) {
590        self.read_concurrency = c.max(1);
591    }
592
593    /// The reader's concurrent-range fan-out (1 = strictly sequential).
594    pub(crate) fn read_concurrency(&self) -> usize {
595        self.read_concurrency
596    }
597
598    /// Did any tile fetch fail since this index was opened — or since the last
599    /// [`reset_load_failure`](Self::reset_load_failure)?
600    pub fn load_incomplete(&self) -> bool {
601        self.load_failed.load(std::sync::atomic::Ordering::Relaxed)
602    }
603
604    /// Forget recorded fetch failures — the start-of-evaluation reset for a
605    /// RESIDENT session, making the incompleteness verdict per-query instead of
606    /// per-open (one transient network blip used to fail every later query on
607    /// the session). Safe because failed tiles are never cached: the next scan
608    /// simply retries them.
609    pub fn reset_load_failure(&self) {
610        self.load_failed
611            .store(false, std::sync::atomic::Ordering::Relaxed);
612    }
613
614    /// True for a remote/lazy index (tiles fault in over a `RangeReader`). The
615    /// join planner uses this to pick read-aware strategies: here a per-row index
616    /// probe is a network round-trip, not a memory lookup, so a left-deep scan +
617    /// hash join often beats probing a moderately-sized prefix.
618    pub fn is_remote(&self) -> bool {
619        self.loader.is_some()
620    }
621
622    /// Batch-fault the unloaded tiles in `[start, end)` of `section` through
623    /// the bulk loader, if one is attached and at least two tiles are missing
624    /// (a single missing tile costs the same either way). A failed batch is
625    /// not an error here: the tiles stay unloaded and the per-tile loader
626    /// retries each one (recording failures) when the scan reaches it.
627    fn prefetch_span(&self, section: usize, start: usize, end: usize) {
628        let missing: Vec<usize> = (start..end)
629            .filter(|&ti| self.sections[section][ti].data.get().is_none())
630            .collect();
631        self.bulk_fault(section, &missing);
632    }
633
634    /// Bulk-fault a set of (possibly scattered, ascending) missing tile indices in
635    /// `section` through the bulk loader in one coalesced read, storing each image.
636    /// No-op without a bulk loader or for fewer than two tiles (a single fault
637    /// costs the same via the per-tile loader). A failed batch leaves the tiles
638    /// unloaded for the per-tile loader to retry. Shared by the consecutive-span
639    /// scan prefetch and the scattered batch-probe prefetch.
640    fn bulk_fault(&self, section: usize, tiles: &[usize]) {
641        if tiles.len() < 2 {
642            return;
643        }
644        let Some(bulk) = &self.bulk else { return };
645        if let Some(images) = bulk(section, tiles) {
646            if images.len() == tiles.len() {
647                for (&ti, img) in tiles.iter().zip(images) {
648                    let _ = self.sections[section][ti].data.set(img);
649                }
650            }
651        }
652    }
653
654    /// Batch-fault the tiles a set of upcoming **probe** patterns will route to,
655    /// before they are probed one at a time. Each probe (a bound leading id)
656    /// routes to a single tile; gathered across the batch those tiles are
657    /// scattered, so faulting them together turns N sequential remote round trips
658    /// into a few coalesced parallel reads — the read-amplification win for a
659    /// label-heavy join over a lazy reader. Honors the synopsis prune (never
660    /// fetches a tile a bound secondary proves can't match). No-op for a local
661    /// index or when fewer than two tiles per section need faulting.
662    pub(crate) fn prefetch_probe_tiles(&self, patterns: &[Pattern]) {
663        if self.bulk.is_none() {
664            return;
665        }
666        let mut want: [std::collections::BTreeSet<usize>; NUM_PERMS] = Default::default();
667        for &pat in patterns {
668            let perm = Self::best_permutation(pat);
669            let [pa, pb, pc] = perm.order_pattern(pat);
670            let si = perm.section_index();
671            let (start, end) = self.tile_span(si, pa);
672            for ti in start..end {
673                if self.sections[si][ti].syn_admits(pb, pc)
674                    && self.sections[si][ti].data.get().is_none()
675                {
676                    want[si].insert(ti);
677                }
678            }
679        }
680        for (si, set) in want.iter().enumerate() {
681            let tiles: Vec<usize> = set.iter().copied().collect();
682            self.bulk_fault(si, &tiles);
683        }
684    }
685
686    /// The tile's block image, faulting it in through the loader if remote.
687    /// A FAILED fetch records the failure and returns an empty slice WITHOUT
688    /// caching it, so a later evaluation retries the tile — a transient
689    /// network error must not permanently poison a long-lived (resident)
690    /// session with an empty tile masquerading as data.
691    fn tile_data(&self, section: usize, tile: usize) -> &[u8] {
692        let cell = &self.sections[section][tile].data;
693        if let Some(d) = cell.get() {
694            return d;
695        }
696        match &self.loader {
697            Some(load) => match load(section, tile) {
698                Some(bytes) => cell.get_or_init(|| bytes),
699                None => {
700                    self.load_failed
701                        .store(true, std::sync::atomic::Ordering::Relaxed);
702                    &[]
703                }
704            },
705            // A local tile with no data was constructed empty on purpose.
706            None => cell.get_or_init(Vec::new),
707        }
708    }
709
710    /// Total triple count (sum of the SPO tiles' zone counts). For a remote
711    /// index this faults in the SPO tiles — prefer the header's quad count.
712    pub fn triple_count(&self) -> u32 {
713        self.prefetch_span(0, 0, self.sections[0].len());
714        (0..self.sections[0].len())
715            .filter_map(|ti| TripleBlock::parse(self.tile_data(0, ti)).ok())
716            .map(|b| b.zone().count)
717            .sum()
718    }
719
720    /// The tiles of each permutation section (in `ALL_PERMS` order), for the
721    /// file writer.
722    pub fn tile_sections(&self) -> [&[Tile]; NUM_PERMS] {
723        [
724            &self.sections[0],
725            &self.sections[1],
726            &self.sections[2],
727            &self.sections[3],
728            &self.sections[4],
729            &self.sections[5],
730        ]
731    }
732
733    /// The permutation selected for a pattern: the one with the longest bound
734    /// prefix. Ties keep the canonical SPO order (then POS, then OSP), which
735    /// makes provenance stable for unbound or equally selective shapes and routes
736    /// a fully unbound pattern to the SPO block rather than fetching all three.
737    pub fn best_permutation(pattern: Pattern) -> IndexPermutation {
738        let mut best = IndexPermutation::Spo;
739        let mut best_score = best.leading_bound(pattern);
740        for perm in ALL_PERMS {
741            let score = perm.leading_bound(pattern);
742            if score > best_score {
743                best = perm;
744                best_score = score;
745            }
746        }
747        best
748    }
749
750    /// Choose a permutation that **routes** on `pattern`'s bound prefix *and*
751    /// streams sorted on the canonical column `sort_col` (0=subject, 1=predicate,
752    /// 2=object) — i.e. `sort_col` is the leading *free* component after the bound
753    /// prefix. Returns `None` if `sort_col` is itself bound (then every row shares
754    /// that value — any permutation is "sorted" on it) or no permutation qualifies.
755    /// This is the precondition a merge join needs: both inputs sorted on the join
756    /// key. Among qualifiers, the longest bound prefix (best routing) wins.
757    pub fn permutation_sorted_on(pattern: Pattern, sort_col: usize) -> Option<IndexPermutation> {
758        let bound = [
759            pattern.0.is_some(),
760            pattern.1.is_some(),
761            pattern.2.is_some(),
762        ];
763        if bound[sort_col] {
764            return None;
765        }
766        let mut best: Option<(IndexPermutation, usize)> = None;
767        for perm in ALL_PERMS {
768            let roles = perm.roles();
769            let lead = perm.leading_bound(pattern);
770            // The component at slot `lead` is the first free one; it must be the
771            // sort column (so the stream is sorted on it after the bound prefix).
772            if lead < 3 && roles[lead] == sort_col && best.map(|(_, s)| lead > s).unwrap_or(true) {
773                best = Some((perm, lead));
774            }
775        }
776        best.map(|(p, _)| p)
777    }
778
779    /// Match one already-decoded serialized permutation block (a single v0.1
780    /// section or one v0.2 tile). This is the core primitive for range-routed
781    /// readers: the caller fetches only the selected payload, then this scans
782    /// it as if it came from a full [`GraphIndex`].
783    pub fn match_serialized_block(
784        bytes: &[u8],
785        permutation: IndexPermutation,
786        pattern: Pattern,
787    ) -> Vec<Triple> {
788        let [pa, pb, pc] = permutation.order_pattern(pattern);
789        let mut out: Vec<Triple> = TripleBlock::parse(bytes)
790            .ok()
791            .filter(|b| b.zone().may_contain(pa, pb, pc))
792            .map(|b| b.scan(pa, pb, pc))
793            .into_iter()
794            .flatten()
795            .map(move |abc| permutation.back(abc))
796            .collect();
797        out.sort_unstable();
798        out
799    }
800
801    /// All triples matching `pattern`, returned in canonical `(s, p, o)` order.
802    ///
803    /// Thin eager wrapper over [`scan_iter`](Self::scan_iter): collect the lazy
804    /// stream and restore the canonical sort (the stream is sorted in the chosen
805    /// permutation's order, which differs from canonical once `perm.back`
806    /// permutes the free components).
807    pub fn match_pattern(&self, pattern: Pattern) -> Vec<Triple> {
808        let mut out: Vec<Triple> = self.scan_iter(pattern).collect();
809        out.sort_unstable();
810        out
811    }
812
813    /// Lazily stream the triples matching `pattern` in canonical `(s, p, o)`
814    /// order *within the chosen permutation* — the streaming entry point for
815    /// callers that can stop early (ASK, `LIMIT`, BGP probes) or that don't need
816    /// the canonical re-sort. Decodes only the matching groups (see
817    /// [`TripleBlock::scan`]); a malformed/absent block yields nothing rather
818    /// than panicking. The permutation is chosen for the longest bound prefix.
819    pub fn scan_iter(&self, pattern: Pattern) -> impl Iterator<Item = Triple> + '_ {
820        self.scan_iter_with(pattern, Self::best_permutation(pattern))
821    }
822
823    /// Stream `pattern`'s matches **sorted on the canonical column `sort_col`**
824    /// (0=subject, 1=predicate, 2=object): the stream's `sort_col` values are
825    /// ascending. Chooses a permutation that routes on the bound prefix *and*
826    /// orders by `sort_col` ([`permutation_sorted_on`](Self::permutation_sorted_on));
827    /// `None` when none qualifies (e.g. `sort_col` is itself bound). The
828    /// precondition for feeding a [merge join](crate::bgp): both inputs sorted on
829    /// the shared join key.
830    pub(crate) fn scan_iter_sorted_on(
831        &self,
832        pattern: Pattern,
833        sort_col: usize,
834    ) -> Option<impl Iterator<Item = Triple> + '_> {
835        Some(self.scan_iter_with(pattern, Self::permutation_sorted_on(pattern, sort_col)?))
836    }
837
838    /// Stream `pattern`'s matches using a **specific** permutation `perm`; the
839    /// stream is sorted in `perm`'s `(a, b, c)` order. Shared core of `scan_iter`
840    /// and `scan_iter_sorted_on`.
841    fn scan_iter_with(
842        &self,
843        pattern: Pattern,
844        perm: IndexPermutation,
845    ) -> impl Iterator<Item = Triple> + '_ {
846        // Route: a bound leading id binary-searches the tile directory to exactly
847        // one tile (groups are never split across tiles); an unbound one chains
848        // every tile's cursor. Within a tile, a bound leading scan jumps to its
849        // a-group via the tile's lazily-built group directory — built on first
850        // use, costing one walk of that (budget-sized) tile.
851        let [pa, pb, pc] = perm.order_pattern(pattern);
852        let si = perm.section_index();
853        let (start, end) = self.tile_span(si, pa);
854        // Coalesce tile faults, but ramp the prefetch window geometrically as
855        // the scan advances rather than fetching the whole span up front: a
856        // small `LIMIT` (which stops pulling early) then never faults tiles
857        // past the rows it needs, while a full scan still batches into a
858        // handful of coalesced reads (4, 8, 16, … tiles). A bound leading scan
859        // spans a single tile, so the prefetch no-ops and it faults just that
860        // one tile, unchanged.
861        let window = std::cell::Cell::new(PREFETCH_WINDOW_START);
862        (start..end)
863            // Synopsis pre-fault prune: drop a routed tile the directory proves
864            // can't match a bound secondary component, **without fetching it**
865            // (the remote win — a negative/sparse lookup costs zero tile reads).
866            // A bound leading id routes to a single tile, so this is where it
867            // bites; a fully-unbound leading scan leaves `pb`/`pc` unbound, so
868            // `syn_admits` keeps every tile, unchanged.
869            .filter(move |&ti| self.sections[si][ti].syn_admits(pb, pc))
870            .flat_map(move |ti| {
871                // Fault in (if remote), parse (untrusted bytes ⇒ `None` on
872                // malformed), and zone-prune per tile, then stream the
873                // matching groups.
874                if self.sections[si][ti].data.get().is_none() {
875                    let w = window.get();
876                    self.prefetch_span(si, ti, (ti + w).min(end));
877                    window.set(w.saturating_mul(2).min(PREFETCH_WINDOW_MAX));
878                }
879                let tile = &self.sections[si][ti];
880                TripleBlock::parse(self.tile_data(si, ti))
881                    .ok()
882                    .filter(|b| b.zone().may_contain(pa, pb, pc))
883                    .map(|b| match pa {
884                        Some(a) => {
885                            let dir = tile.dir.get_or_init(|| b.group_directory());
886                            b.scan_from(dir, a, pb, pc)
887                        }
888                        None => b.scan(pa, pb, pc),
889                    })
890                    .into_iter()
891                    .flatten()
892            })
893            .map(move |abc| perm.back(abc))
894    }
895
896    /// The tile index span a scan must visit: every tile when the leading
897    /// component is unbound, else the run of tiles whose leading-id ranges
898    /// cover it. Tile ranges are ascending; consecutive tiles may SHARE a
899    /// leading id when a mega-group was split across tiles (see
900    /// [`build_tiles`]), so the span is a range scan, not a single hit —
901    /// files without splits still yield a span of at most one tile.
902    pub(crate) fn tile_span(&self, section: usize, pa: Option<u32>) -> (usize, usize) {
903        let tiles = &self.sections[section];
904        match pa {
905            None => (0, tiles.len()),
906            Some(a) => {
907                let i = tiles.partition_point(|t| t.max_a < a);
908                let mut j = i;
909                while j < tiles.len() && tiles[j].min_a <= a {
910                    j += 1;
911                }
912                (i, j)
913            }
914        }
915    }
916}
917
918#[cfg(test)]
919mod tests {
920    use super::*;
921
922    fn graph() -> (GraphIndex, Vec<Triple>) {
923        let data = vec![
924            (1, 10, 100),
925            (1, 10, 101),
926            (1, 11, 100),
927            (2, 10, 100),
928            (2, 12, 200),
929            (3, 11, 300),
930        ];
931        let mut b = GraphIndexBuilder::new();
932        for &t in &data {
933            b.push(t);
934        }
935        (b.build(), data)
936    }
937
938    /// Brute-force reference for a pattern.
939    fn reference(data: &[Triple], (s, p, o): Pattern) -> Vec<Triple> {
940        let mut v: Vec<Triple> = data
941            .iter()
942            .copied()
943            .filter(|&(a, b, c)| {
944                s.is_none_or(|x| x == a) && p.is_none_or(|x| x == b) && o.is_none_or(|x| x == c)
945            })
946            .collect();
947        v.sort_unstable();
948        v
949    }
950
951    #[test]
952    fn every_pattern_shape_matches_reference() {
953        let (idx, data) = graph();
954        let vals = |opts: &[u32]| {
955            let mut v: Vec<Option<u32>> = opts.iter().map(|&x| Some(x)).collect();
956            v.push(None);
957            v
958        };
959        // Exercise all 8 bound/unbound shapes across representative values.
960        for s in vals(&[1, 2, 9]) {
961            for p in vals(&[10, 11, 99]) {
962                for o in vals(&[100, 300, 999]) {
963                    let pat = (s, p, o);
964                    assert_eq!(
965                        idx.match_pattern(pat),
966                        reference(&data, pat),
967                        "pattern {pat:?}"
968                    );
969                    // The lazy stream must match the eager result once sorted —
970                    // same triples, just without the up-front canonical re-sort.
971                    let mut streamed: Vec<Triple> = idx.scan_iter(pat).collect();
972                    streamed.sort_unstable();
973                    assert_eq!(streamed, reference(&data, pat), "scan_iter {pat:?}");
974                }
975            }
976        }
977    }
978
979    #[test]
980    fn unbound_returns_everything_sorted() {
981        let (idx, data) = graph();
982        let mut sorted = data.clone();
983        sorted.sort_unstable();
984        assert_eq!(idx.match_pattern((None, None, None)), sorted);
985    }
986
987    /// A tiny tile budget must split sections into many tiles — including
988    /// MID-GROUP cuts (budget 1/16 makes every a-group oversized) — and every
989    /// pattern shape must still match the brute-force reference: bound
990    /// leading ids route to the run of covering tiles, unbound scans chain
991    /// all tiles.
992    #[test]
993    fn multi_tile_sections_match_reference_every_shape() {
994        // Enough distinct leading ids to split under a tiny budget.
995        let mut data: Vec<Triple> = Vec::new();
996        for s in 1..=40u32 {
997            for p in [10u32, 11] {
998                for o in [100u32, 100 + s] {
999                    data.push((s, p, o));
1000                }
1001            }
1002        }
1003        for budget in [1usize, 16, 64, 1 << 20] {
1004            let mut b = GraphIndexBuilder::new().with_tile_budget(budget);
1005            for &t in &data {
1006                b.push(t);
1007            }
1008            let idx = b.build();
1009            let spo_tiles = idx.tile_sections()[0].len();
1010            if budget <= 16 {
1011                assert!(spo_tiles > 1, "budget {budget} should force tiling");
1012            }
1013            // Tile ranges must be ascending; a split mega-group may leave
1014            // consecutive tiles SHARING a leading id (never overlapping past it).
1015            for w in idx.tile_sections()[0].windows(2) {
1016                assert!(w[0].leading_range().1 <= w[1].leading_range().0);
1017            }
1018            assert_eq!(idx.triple_count() as usize, data.len(), "budget {budget}");
1019
1020            let vals = |opts: &[u32]| {
1021                let mut v: Vec<Option<u32>> = opts.iter().map(|&x| Some(x)).collect();
1022                v.push(None);
1023                v
1024            };
1025            for _round in 0..2 {
1026                for s in vals(&[1, 20, 40, 99]) {
1027                    for p in vals(&[10, 11, 99]) {
1028                        for o in vals(&[100, 120, 999]) {
1029                            let pat = (s, p, o);
1030                            assert_eq!(
1031                                idx.match_pattern(pat),
1032                                reference(&data, pat),
1033                                "budget {budget} pattern {pat:?}"
1034                            );
1035                        }
1036                    }
1037                }
1038            }
1039        }
1040    }
1041
1042    /// The Crossref shape: ONE mega-predicate carrying most of the graph. At
1043    /// the real 64 KiB budget its P-leading a-group must be split across
1044    /// multiple tiles (bounding builder memory), and a bound leading id must
1045    /// route to the whole run of covering tiles with complete results.
1046    #[test]
1047    fn mega_group_splits_across_tiles_and_lookups_stay_complete() {
1048        let hot_p = 7u32;
1049        let mut data: Vec<Triple> = Vec::new();
1050        for i in 0..40_000u32 {
1051            data.push((1_000 + i % 200, hot_p, 50_000 + i));
1052        }
1053        data.push((1, 1, 1));
1054        data.push((2, 2, 2));
1055        let mut b = GraphIndexBuilder::new(); // default INDEX_TILE_BUDGET
1056        for &t in &data {
1057            b.push(t);
1058        }
1059        let idx = b.build();
1060
1061        // PSO (a = predicate): the hot_p group must span several tiles.
1062        let pso = ALL_PERMS
1063            .iter()
1064            .position(|p| matches!(p, IndexPermutation::Pso))
1065            .unwrap();
1066        let covering = idx.tile_sections()[pso]
1067            .iter()
1068            .filter(|t| {
1069                let (lo, hi) = t.leading_range();
1070                lo <= hot_p && hot_p <= hi
1071            })
1072            .count();
1073        assert!(
1074            covering > 1,
1075            "expected the hot predicate split across tiles, got {covering}"
1076        );
1077
1078        // Bound-p lookup must still return every triple of the mega-group —
1079        // probing bound objects in the FIRST, MIDDLE and LAST slices of the
1080        // split run (a mid-run object once returned 0 on the first split file).
1081        for pat in [
1082            (None, Some(hot_p), None),
1083            (Some(1_050), Some(hot_p), None),
1084            (None, Some(hot_p), Some(50_123)), // first slice
1085            (None, Some(hot_p), Some(70_000)), // middle of the run
1086            (None, Some(hot_p), Some(89_999)), // last slice
1087            (None, Some(hot_p), Some(49_000)), // below the range → empty
1088            (None, Some(hot_p), Some(95_000)), // above the range → empty
1089        ] {
1090            assert_eq!(idx.match_pattern(pat), reference(&data, pat), "{pat:?}");
1091        }
1092    }
1093
1094    /// Scans must yield identical results across the directory lifecycle: the
1095    /// first leading-bound scan walks linearly, the second builds the
1096    /// directory, and later ones jump through it — including absent ids
1097    /// (between, below, and above the stored groups).
1098    #[test]
1099    fn directory_backed_scans_match_reference_every_shape() {
1100        let (idx, data) = graph();
1101        let vals = |opts: &[u32]| {
1102            let mut v: Vec<Option<u32>> = opts.iter().map(|&x| Some(x)).collect();
1103            v.push(None);
1104            v
1105        };
1106        for round in 0..3 {
1107            for s in vals(&[1, 2, 3, 0, 9]) {
1108                for p in vals(&[10, 11, 12, 99]) {
1109                    for o in vals(&[100, 200, 300, 999]) {
1110                        let pat = (s, p, o);
1111                        let mut got: Vec<Triple> = idx.scan_iter(pat).collect();
1112                        got.sort_unstable();
1113                        assert_eq!(got, reference(&data, pat), "round {round} scan {pat:?}");
1114                    }
1115                }
1116            }
1117        }
1118    }
1119
1120    /// A tile synopsis must let a routed scan **skip the fetch** of a tile its
1121    /// secondary-column range rules out — and never skip one that could match.
1122    #[test]
1123    fn synopsis_prunes_the_routed_tile_before_fetch() {
1124        use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
1125        use std::sync::Arc;
1126
1127        // One real SPO tile: subject a=5, predicates b∈{10,11}, object c=100.
1128        let block = {
1129            let mut b = TripleBlockBuilder::new();
1130            b.push((5, 10, 100));
1131            b.push((5, 11, 100));
1132            b.build()
1133        };
1134        let fetches = Arc::new(AtomicUsize::new(0));
1135        let (blk, fc) = (block.clone(), fetches.clone());
1136        // The loader returns the (already-decompressed) tile image and counts calls.
1137        let loader: TileLoader = Box::new(move |_si, _ti| {
1138            fc.fetch_add(1, SeqCst);
1139            Some(blk.clone())
1140        });
1141        // Remote SPO directory: one tile, leading a∈[5,5], synopsis b∈[10,11], c∈[100,100].
1142        let dirs = [
1143            vec![(5u32, 5u32, Some((10u32, 11u32, 100u32, 100u32)))],
1144            Vec::new(),
1145            Vec::new(),
1146            Vec::new(),
1147            Vec::new(),
1148            Vec::new(),
1149        ];
1150        let idx = GraphIndex::from_remote_directories(dirs, loader);
1151
1152        // Predicate 99 is outside the tile's b-range → prune, zero fetches, empty.
1153        assert!(idx.match_pattern((Some(5), Some(99), None)).is_empty());
1154        assert_eq!(fetches.load(SeqCst), 0, "synopsis must skip the fetch");
1155
1156        // Object 999 outside the tile's c-range → prune, still zero fetches.
1157        assert!(idx.match_pattern((Some(5), None, Some(999))).is_empty());
1158        assert_eq!(
1159            fetches.load(SeqCst),
1160            0,
1161            "secondary-c prune also skips the fetch"
1162        );
1163
1164        // Predicate 10 is in range → the tile is fetched and the match returned.
1165        assert_eq!(
1166            idx.match_pattern((Some(5), Some(10), None)),
1167            vec![(5, 10, 100)]
1168        );
1169        assert_eq!(
1170            fetches.load(SeqCst),
1171            1,
1172            "an admissible secondary still fetches"
1173        );
1174    }
1175
1176    /// Without a synopsis (`None`), nothing is pruned early — the tile is always
1177    /// fetched and the in-tile zone map does the (correct) pruning, as before.
1178    #[test]
1179    fn absent_synopsis_never_prunes() {
1180        use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
1181        use std::sync::Arc;
1182        let block = {
1183            let mut b = TripleBlockBuilder::new();
1184            b.push((5, 10, 100));
1185            b.build()
1186        };
1187        let fetches = Arc::new(AtomicUsize::new(0));
1188        let (blk, fc) = (block.clone(), fetches.clone());
1189        let loader: TileLoader = Box::new(move |_si, _ti| {
1190            fc.fetch_add(1, SeqCst);
1191            Some(blk.clone())
1192        });
1193        let dirs = [
1194            vec![(5u32, 5u32, None)],
1195            Vec::new(),
1196            Vec::new(),
1197            Vec::new(),
1198            Vec::new(),
1199            Vec::new(),
1200        ];
1201        let idx = GraphIndex::from_remote_directories(dirs, loader);
1202        // Predicate 99 absent, but with no synopsis the tile is fetched (then the
1203        // zone map yields no match) — correctness preserved, just no fetch saved.
1204        assert!(idx.match_pattern((Some(5), Some(99), None)).is_empty());
1205        assert_eq!(fetches.load(SeqCst), 1, "no synopsis ⇒ no early prune");
1206    }
1207}