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}