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::Arc;
17
18use crate::chunk_cache::{CacheEntry, ChunkCache};
19use crate::triples::{GroupDirectory, Triple, TripleBlock, TripleBlockBuilder};
20
21/// A triple pattern: `None` is an unbound variable, `Some(id)` a bound term.
22pub type Pattern = (Option<u32>, Option<u32>, Option<u32>);
23
24/// Default per-tile budget in (uncompressed) encoded bytes. Tiles are the
25/// independently-compressed, independently-fetchable units of a permutation
26/// section: ~64 KiB matches both zstd's sweet spot and one HTTP range read.
27/// The budget a build uses is [`crate::write_opts::tile_budget`] (`--tile-kib`).
28pub const INDEX_TILE_BUDGET: usize = crate::write_opts::DEFAULT_TILE_BUDGET_BYTES;
29
30/// Geometric tile-prefetch ramp for an unbound (multi-tile) scan (see
31/// [`GraphIndex::scan_iter`]). The first coalesced batch faults this many
32/// tiles; each subsequent batch doubles up to [`PREFETCH_WINDOW_MAX`]. Small
33/// enough that `LIMIT 1` fetches only a few tiles, large enough that a full
34/// scan still coalesces into a handful of range reads.
35const PREFETCH_WINDOW_START: usize = 4;
36const PREFETCH_WINDOW_MAX: usize = 512;
37
38/// Fetches one tile's (uncompressed) block image on demand: the bridge that
39/// lets a remote `GraphIndex` fault tiles in over a `RangeReader` without this
40/// module knowing about I/O. `None` = the fetch failed; the index records the
41/// failure ([`GraphIndex::load_incomplete`]) and the scan sees an empty tile —
42/// callers running over remote data MUST check the flag after evaluating.
43pub type TileLoader = Box<dyn Fn(usize, usize) -> Option<Vec<u8>> + Send + Sync>;
44
45/// Fetches **many** tiles of one section in one round trip: given the section
46/// and the (ascending) indices of the tiles wanted, returns each tile's
47/// uncompressed block image in the same order. The ranged reader implements
48/// this by coalescing byte-adjacent tile ranges into single range reads, so a
49/// full-section scan costs a handful of requests instead of one per tile.
50/// `None` = the batch failed as a whole; callers fall back to the per-tile
51/// [`TileLoader`] (which records per-tile failures).
52pub type TileBulkLoader = Box<dyn Fn(usize, &[usize]) -> Option<Vec<Vec<u8>>> + Send + Sync>;
53
54/// One tile of a permutation section: a fully self-contained [`TripleBlock`]
55/// over a consecutive run of whole a-groups, plus the leading-id range it
56/// covers (for routing without parsing or fetching) and its lazily-built group
57/// directory (for intra-tile point lookups). The block image itself may be
58/// local (built/decoded eagerly) or faulted in by the index's [`TileLoader`].
59pub struct Tile {
60 min_a: u32,
61 max_a: u32,
62 /// Optional **tile synopsis**: the inclusive min/max of the two non-leading
63 /// columns `(min_b, max_b, min_c, max_c)`, read from the directory trailer of
64 /// a synopsis-carrying file. Lets a scan prune this tile by a bound secondary
65 /// component *before* faulting it (remote reads). `None` = no synopsis (an
66 /// older file, or a locally-built/opened tile) — then nothing is pruned early
67 /// and the in-tile zone map prunes after the tile is in hand, as before.
68 pub(crate) syn: Option<(u32, u32, u32, u32)>,
69 /// Encoded byte length of this tile (compressed on-disk size for a remote
70 /// tile, in-memory image size for a local one; 0 = unknown). Feeds the join
71 /// planner's fatness gates without faulting any data.
72 len: u32,
73 /// Build-time staging only: a locally-built or locally-decoded tile's body
74 /// waits here until [`GraphIndex::from_sections`] moves it into the shared
75 /// [`ChunkCache`] (keyed by `(section, tile)`) and clears it. `None`
76 /// afterward, and for a remote tile whose body faults in through the loader.
77 /// The long-lived retention is the cache, not this field.
78 staged: Option<Vec<u8>>,
79}
80
81impl Tile {
82 fn local(min_a: u32, max_a: u32, bytes: Vec<u8>) -> Self {
83 let len = bytes.len().min(u32::MAX as usize) as u32;
84 Tile {
85 min_a,
86 max_a,
87 syn: None,
88 len,
89 staged: Some(bytes),
90 }
91 }
92
93 fn remote(min_a: u32, max_a: u32, syn: Option<(u32, u32, u32, u32)>) -> Self {
94 Tile {
95 min_a,
96 max_a,
97 syn,
98 len: 0,
99 staged: None,
100 }
101 }
102
103 /// Encoded byte length (see the field doc); falls back to a still-staged
104 /// body's size when the directory didn't provide one.
105 pub(crate) fn encoded_len(&self) -> u64 {
106 if self.len > 0 {
107 self.len as u64
108 } else {
109 self.staged.as_ref().map_or(0, |d| d.len() as u64)
110 }
111 }
112
113 /// Leading-component (permuted `a`) range this tile covers, inclusive.
114 pub fn leading_range(&self) -> (u32, u32) {
115 (self.min_a, self.max_a)
116 }
117
118 /// Could this tile hold a triple with the given bound non-leading components,
119 /// per its synopsis? `true` when there is no synopsis (can't rule it out) or
120 /// the bound `b`/`c` fall inside the recorded ranges. Conservative: a `false`
121 /// is a *proven* miss (the in-tile zone map would reject the same tile), so
122 /// skipping the fetch never drops a result.
123 pub(crate) fn syn_admits(&self, pb: Option<u32>, pc: Option<u32>) -> bool {
124 match self.syn {
125 None => true,
126 Some((min_b, max_b, min_c, max_c)) => {
127 let ok = |v: Option<u32>, lo: u32, hi: u32| v.is_none_or(|x| lo <= x && x <= hi);
128 ok(pb, min_b, max_b) && ok(pc, min_c, max_c)
129 }
130 }
131 }
132}
133
134/// What a routed scan of one pattern will touch, from the tile directories
135/// alone — see [`GraphIndex::scan_plan`].
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub struct ScanPlan {
138 /// The permutation the scan routes to (always inside [`PermSet::CORE`]).
139 pub permutation: IndexPermutation,
140 /// Tiles in that permutation's section — the denominator.
141 pub tiles_total: usize,
142 /// Tiles left after routing on the bound leading component.
143 pub tiles_routed: usize,
144 /// Tiles left after the synopsis prune — what the scan will actually fetch.
145 pub tiles_admitted: usize,
146 /// Encoded (on-disk) bytes of the admitted tiles: the index-side upper
147 /// bound on the scan's range reads.
148 pub tile_bytes: u64,
149 /// Encoded bytes of the whole section — what an unpruned scan would read.
150 pub section_bytes: u64,
151}
152
153/// Which stored permutation to scan. The full **six** orders (a SPARQL engine's
154/// classic set, as in QLever): together they sort the triples on *every* prefix
155/// of `(s, p, o)` columns, so for any bound-component prefix and any free
156/// component there is a permutation that routes on the prefix **and** yields the
157/// stream sorted on that free component — the precondition for a merge join.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum IndexPermutation {
160 Spo,
161 Sop,
162 Pso,
163 Pos,
164 Osp,
165 Ops,
166}
167
168/// The number of permutations the format can address (the width of every
169/// per-permutation array). How many a *given file* stores is its [`PermSet`].
170pub(crate) const NUM_PERMS: usize = 6;
171
172/// The six permutations in section order — the canonical iteration list. The
173/// original three (SPO, POS, OSP) lead, so a default scan's tie-break (and its
174/// provenance) is unchanged from the 3-permutation format; the three added orders
175/// (SOP, PSO, OPS) follow and exist to give a merge join a stream sorted on the
176/// join key.
177pub(crate) const ALL_PERMS: [IndexPermutation; NUM_PERMS] = [
178 IndexPermutation::Spo,
179 IndexPermutation::Pos,
180 IndexPermutation::Osp,
181 IndexPermutation::Sop,
182 IndexPermutation::Pso,
183 IndexPermutation::Ops,
184];
185
186/// **Which** permutations a file stores — a 6-bit set over
187/// [`IndexPermutation::section_index`], recorded in the header so a reader
188/// never has to assume six.
189///
190/// Every legal set is a superset of [`PermSet::CORE`] = {SPO, POS, OSP}. That
191/// is not a convention: those three **tie the longest bound prefix on all eight
192/// triple-pattern shapes**, so [`GraphIndex::best_permutation`] never selects
193/// outside them and a lean file routes every pattern exactly where a full one
194/// does — same tiles, same rows. What the other three (SOP, PSO, OPS) add is a
195/// *sort order*: they let [`GraphIndex::permutation_sorted_on`] hand a
196/// sort-merge join a co-sorted stream for three of the twelve
197/// (bound-set, join-column) shapes it otherwise has to hash-join. Absent, the
198/// merge seed declines and the hash path answers — the cost is a fast path,
199/// never correctness (`bgp::try_merge_join` already returns `Option`).
200///
201/// `CORE` is the smallest legal set, [`PermSet::ALL`] is the default and what
202/// every file written before this existed carries.
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
204pub struct PermSet(u8);
205
206impl Default for PermSet {
207 fn default() -> Self {
208 PermSet::ALL
209 }
210}
211
212impl PermSet {
213 /// All six permutations — the default, and what a `0` header byte means.
214 pub const ALL: PermSet = PermSet(0b0011_1111);
215 /// The three routing permutations {SPO, POS, OSP}: every pattern shape
216 /// routes as well as it does with six, no merge-join sort orders.
217 pub const CORE: PermSet = PermSet(0b0000_0111);
218
219 /// Parse a raw mask. `Err` when it is not a superset of [`PermSet::CORE`]
220 /// or addresses a permutation this build has no section index for — a
221 /// pattern would then route to a permutation the file does not carry, which
222 /// is the one failure mode that must never be silent.
223 pub fn from_bits(bits: u8) -> Result<PermSet, &'static str> {
224 if bits & !PermSet::ALL.0 != 0 {
225 return Err("permutation mask addresses more than six permutations");
226 }
227 if bits & PermSet::CORE.0 != PermSet::CORE.0 {
228 return Err("permutation mask must contain SPO, POS and OSP");
229 }
230 Ok(PermSet(bits))
231 }
232
233 /// The raw 6-bit mask.
234 pub fn bits(self) -> u8 {
235 self.0
236 }
237
238 /// Is `perm` stored in this file?
239 pub fn contains(self, perm: IndexPermutation) -> bool {
240 self.0 & (1 << perm.section_index()) != 0
241 }
242
243 /// How many permutation sections the file's index container holds.
244 pub fn len(self) -> usize {
245 self.0.count_ones() as usize
246 }
247
248 /// Never true: every legal set contains [`PermSet::CORE`]. Present because
249 /// a public `len` without it is a clippy error, and the honest answer is
250 /// more useful than an `allow`.
251 pub fn is_empty(self) -> bool {
252 false
253 }
254
255 /// The stored permutations in [`ALL_PERMS`] (= container) order.
256 pub fn iter(self) -> impl Iterator<Item = IndexPermutation> {
257 ALL_PERMS.into_iter().filter(move |p| self.contains(*p))
258 }
259
260 /// `perm`'s position **inside the index container**, which is its rank
261 /// among the stored permutations — not [`IndexPermutation::section_index`],
262 /// which is its slot in the format's fixed six-wide addressing. The two
263 /// coincide only for [`PermSet::ALL`].
264 pub fn position(self, perm: IndexPermutation) -> Option<usize> {
265 self.contains(perm)
266 .then(|| (self.0 & ((1u8 << perm.section_index()) - 1)).count_ones() as usize)
267 }
268
269 /// Names in container order, e.g. `["SPO", "POS", "OSP"]`.
270 pub fn names(self) -> Vec<&'static str> {
271 self.iter().map(|p| p.name()).collect()
272 }
273
274 /// Does this set carry the three merge-join orders?
275 pub fn has_merge_orders(self) -> bool {
276 self == PermSet::ALL
277 }
278}
279
280impl IndexPermutation {
281 /// Stable display name used in CLI diagnostics and provenance records.
282 pub fn name(self) -> &'static str {
283 match self {
284 IndexPermutation::Spo => "SPO",
285 IndexPermutation::Sop => "SOP",
286 IndexPermutation::Pso => "PSO",
287 IndexPermutation::Pos => "POS",
288 IndexPermutation::Osp => "OSP",
289 IndexPermutation::Ops => "OPS",
290 }
291 }
292
293 /// Section number inside the stored permutation container.
294 pub fn section_index(self) -> usize {
295 match self {
296 IndexPermutation::Spo => 0,
297 IndexPermutation::Pos => 1,
298 IndexPermutation::Osp => 2,
299 IndexPermutation::Sop => 3,
300 IndexPermutation::Pso => 4,
301 IndexPermutation::Ops => 5,
302 }
303 }
304
305 /// The canonical `(s, p, o)` roles in this permutation's `(a, b, c)` slots,
306 /// as indices (0=s, 1=p, 2=o). The single source of truth for `forward` /
307 /// `back` / `order_pattern`.
308 pub(crate) const fn roles(self) -> [usize; 3] {
309 match self {
310 IndexPermutation::Spo => [0, 1, 2],
311 IndexPermutation::Sop => [0, 2, 1],
312 IndexPermutation::Pso => [1, 0, 2],
313 IndexPermutation::Pos => [1, 2, 0],
314 IndexPermutation::Osp => [2, 0, 1],
315 IndexPermutation::Ops => [2, 1, 0],
316 }
317 }
318
319 /// Map a canonical `(s, p, o)` triple into this permutation's `(a, b, c)`.
320 pub(crate) fn forward(self, t: Triple) -> Triple {
321 let c = [t.0, t.1, t.2];
322 let r = self.roles();
323 (c[r[0]], c[r[1]], c[r[2]])
324 }
325
326 /// Map a stored `(a, b, c)` back to canonical `(s, p, o)`.
327 fn back(self, abc: Triple) -> Triple {
328 let a = [abc.0, abc.1, abc.2];
329 let r = self.roles();
330 let mut out = [0u32; 3];
331 out[r[0]] = a[0];
332 out[r[1]] = a[1];
333 out[r[2]] = a[2];
334 (out[0], out[1], out[2])
335 }
336
337 /// The pattern's bound components in this permutation's component order.
338 pub(crate) fn order_pattern(self, p: Pattern) -> [Option<u32>; 3] {
339 let c = [p.0, p.1, p.2];
340 let r = self.roles();
341 [c[r[0]], c[r[1]], c[r[2]]]
342 }
343
344 /// Length of the leading run of bound components — higher is more selective.
345 fn leading_bound(self, pat: Pattern) -> usize {
346 self.order_pattern(pat)
347 .iter()
348 .take_while(|c| c.is_some())
349 .count()
350 }
351}
352
353/// Build a [`GraphIndex`] from canonical `(s, p, o)` integer triples.
354pub struct GraphIndexBuilder {
355 triples: Vec<Triple>,
356 tile_budget: usize,
357 perms: PermSet,
358}
359
360impl Default for GraphIndexBuilder {
361 fn default() -> Self {
362 Self {
363 triples: Vec::new(),
364 tile_budget: crate::write_opts::tile_budget(),
365 perms: PermSet::ALL,
366 }
367 }
368}
369
370impl GraphIndexBuilder {
371 pub fn new() -> Self {
372 Self::default()
373 }
374
375 /// Build directly from an owned triple vector — moves it in, avoiding the
376 /// per-triple `push` copy when the caller already has the id-triples in a
377 /// `Vec` (the assembly path, where they double as the pyramid input).
378 pub fn from_triples(triples: Vec<Triple>) -> Self {
379 Self {
380 triples,
381 tile_budget: crate::write_opts::tile_budget(),
382 perms: PermSet::ALL,
383 }
384 }
385
386 /// Override the per-tile byte budget (tests use tiny budgets to force
387 /// multi-tile sections on small data).
388 pub fn with_tile_budget(mut self, bytes: usize) -> Self {
389 self.tile_budget = bytes.max(1);
390 self
391 }
392
393 /// Build only the permutations in `perms` (default [`PermSet::ALL`]).
394 /// Sections outside the set come out empty and are not written to the file.
395 pub fn with_perms(mut self, perms: PermSet) -> Self {
396 self.perms = perms;
397 self
398 }
399
400 pub fn push(&mut self, t: Triple) {
401 self.triples.push(t);
402 }
403
404 /// Build the six permutations **one at a time** (each permutation's internal
405 /// sort still uses every core), so only a single permuted copy of the triples
406 /// is resident at once instead of all six concurrently. Trades the cross-
407 /// permutation parallelism of [`Self::build`] for a much lower peak RAM — the
408 /// large-graph low-memory path. Output is byte-identical to [`Self::build`].
409 pub fn build_seq(self) -> GraphIndex {
410 let triples = &self.triples;
411 let budget = self.tile_budget;
412 let wanted: Vec<IndexPermutation> = self.perms.iter().collect();
413 // Permute (parallel) + sort (parallel inside `build_tiles`) one permutation
414 // at a time, so only a single permuted copy of the triples is resident — but
415 // every core is still busy. Process permutations in batches of two to
416 // overlap one permutation's tile-building with the next one's sort while
417 // keeping the resident permuted copies to two. Byte-identical to `build`.
418 let build_one = |perm: IndexPermutation| -> Vec<Tile> {
419 #[cfg(feature = "parallel")]
420 let permuted: Vec<Triple> = {
421 use rayon::prelude::*;
422 triples.par_iter().map(|&t| perm.forward(t)).collect()
423 };
424 #[cfg(not(feature = "parallel"))]
425 let permuted: Vec<Triple> = triples.iter().map(|&t| perm.forward(t)).collect();
426 build_tiles(permuted, budget)
427 };
428 #[cfg(feature = "parallel")]
429 let built: Vec<Vec<Tile>> = {
430 use rayon::prelude::*;
431 let mut built: Vec<Vec<Tile>> = Vec::with_capacity(wanted.len());
432 for chunk in wanted.chunks(2) {
433 built.extend(
434 chunk
435 .to_vec()
436 .into_par_iter()
437 .map(build_one)
438 .collect::<Vec<_>>(),
439 );
440 }
441 built
442 };
443 #[cfg(not(feature = "parallel"))]
444 let built: Vec<Vec<Tile>> = wanted.iter().copied().map(build_one).collect();
445 GraphIndex::from_sections(scatter(&wanted, built), self.perms)
446 }
447
448 pub fn build(self) -> GraphIndex {
449 let wanted: Vec<IndexPermutation> = self.perms.iter().collect();
450 let triples = &self.triples;
451 let budget = self.tile_budget;
452 // The permutations are independent — permute + sort + tile each.
453 let build_one = move |perm: IndexPermutation| -> Vec<Tile> {
454 let permuted: Vec<Triple> = triples.iter().map(|&t| perm.forward(t)).collect();
455 build_tiles(permuted, budget)
456 };
457 // Build the permutations concurrently when the `parallel` feature is on
458 // (they share no state); the per-permutation sort inside is also
459 // parallel. Output is byte-identical to the serial path.
460 #[cfg(feature = "parallel")]
461 let built: Vec<Vec<Tile>> = {
462 use rayon::iter::{IntoParallelIterator, ParallelIterator};
463 wanted.clone().into_par_iter().map(build_one).collect()
464 };
465 #[cfg(not(feature = "parallel"))]
466 let built: Vec<Vec<Tile>> = wanted.iter().copied().map(build_one).collect();
467 GraphIndex::from_sections(scatter(&wanted, built), self.perms)
468 }
469}
470
471/// Place each built section at its permutation's fixed [`IndexPermutation::section_index`]
472/// slot, leaving unbuilt permutations empty. The array stays six wide however
473/// few permutations a file carries, so every `sections[perm.section_index()]`
474/// in the engine keeps working unchanged.
475fn scatter(wanted: &[IndexPermutation], built: Vec<Vec<Tile>>) -> [Vec<Tile>; NUM_PERMS] {
476 let mut sections: [Vec<Tile>; NUM_PERMS] = Default::default();
477 for (perm, tiles) in wanted.iter().zip(built) {
478 sections[perm.section_index()] = tiles;
479 }
480 sections
481}
482
483/// The encoded varint length of `v` (LEB128).
484fn varint_len(mut v: u64) -> usize {
485 let mut n = 1;
486 while v >= 0x80 {
487 v >>= 7;
488 n += 1;
489 }
490 n
491}
492
493/// Incremental encoded-size accounting for one a-group of a tiled section —
494/// the running-delta chain of [`build_tiles`], streamable triple by triple.
495/// Shared by the in-memory tiler here and the external build's streaming
496/// tiler ([`crate::extbuild`]) so both choose IDENTICAL tile boundaries and
497/// their outputs stay byte-identical.
498pub(crate) struct GroupSizer {
499 /// finalized contributions: the a-delta plus every closed b-run
500 size: usize,
501 num_b: u64,
502 cur_b: u32,
503 /// the open b-run's c count / last c
504 num_c: u64,
505 prev_c: u32,
506 empty: bool,
507}
508
509impl GroupSizer {
510 /// Start a group for leading id `a` whose delta base is `prev_a` (the
511 /// previous group's leading id; `a` again for a mid-group continuation).
512 pub(crate) fn start(a: u32, prev_a: u32) -> Self {
513 GroupSizer {
514 size: varint_len((a - prev_a) as u64),
515 num_b: 0,
516 cur_b: 0,
517 num_c: 0,
518 prev_c: 0,
519 empty: true,
520 }
521 }
522
523 /// Account one `(b, c)` of this group; returns the group's total encoded
524 /// size so far (open count varints included).
525 pub(crate) fn push(&mut self, b: u32, c: u32) -> usize {
526 if self.empty || b != self.cur_b {
527 if self.empty {
528 self.size += varint_len(b as u64); // first b-run: delta from 0
529 } else {
530 self.size += varint_len(self.num_c); // close the previous b-run
531 self.size += varint_len((b - self.cur_b) as u64);
532 }
533 self.cur_b = b;
534 self.num_c = 0;
535 self.prev_c = 0;
536 self.num_b += 1;
537 self.empty = false;
538 }
539 self.size += varint_len((c - self.prev_c) as u64);
540 self.prev_c = c;
541 self.num_c += 1;
542 self.total()
543 }
544
545 /// The group's total encoded size so far.
546 pub(crate) fn total(&self) -> usize {
547 self.size
548 + if self.empty {
549 0
550 } else {
551 varint_len(self.num_c)
552 }
553 + varint_len(self.num_b)
554 }
555}
556
557/// Split sorted, deduped permuted triples into size-targeted tiles: append
558/// whole a-groups until the (estimated, near-exact) encoded size would exceed
559/// `budget`, then flush — and additionally cut WITHIN an a-group whose own
560/// running size exceeds the budget (a mega-group: one predicate or class
561/// carrying a large share of the graph, e.g. a 2B-triple `cites` predicate in
562/// POS). Split slices become consecutive tiles sharing the leading id — a
563/// bound leading id routes to the whole run of covering tiles (see
564/// [`GraphIndex::tile_span`]) — which bounds both builder memory and the
565/// bytes a remote reader faults for one lookup. Boundary accounting lives in
566/// [`GroupSizer`], shared with the external build for byte-identity.
567fn build_tiles(mut triples: Vec<Triple>, budget: usize) -> Vec<Tile> {
568 #[cfg(feature = "parallel")]
569 {
570 use rayon::slice::ParallelSliceMut;
571 triples.par_sort_unstable();
572 }
573 #[cfg(not(feature = "parallel"))]
574 triples.sort_unstable();
575 triples.dedup();
576 if triples.is_empty() {
577 return Vec::new();
578 }
579
580 let make_tile = |run: &[Triple]| -> Tile {
581 let mut b = TripleBlockBuilder::new();
582 for &t in run {
583 b.push(t);
584 }
585 Tile::local(run[0].0, run[run.len() - 1].0, b.build())
586 };
587
588 let mut tiles = Vec::new();
589 let mut tile_start = 0usize;
590 let mut tile_size = 0usize; // completed groups in the current tile
591 let mut prev_a = 0u32;
592 let mut i = 0usize;
593 while i < triples.len() {
594 let a = triples[i].0;
595 let mut slice_start = i; // group start, or the last mid-group cut
596 let mut sizer = GroupSizer::start(a, prev_a);
597 let mut gtotal = 0usize;
598 while i < triples.len() && triples[i].0 == a {
599 gtotal = sizer.push(triples[i].1, triples[i].2);
600 i += 1;
601 if gtotal > budget {
602 // Mega-group cut: everything buffered — completed groups plus
603 // the slice measured so far — becomes one tile; the group
604 // continues in a fresh chain with `a` as its own delta base.
605 tiles.push(make_tile(&triples[tile_start..i]));
606 tile_start = i;
607 tile_size = 0;
608 prev_a = a;
609 slice_start = i;
610 sizer = GroupSizer::start(a, a);
611 gtotal = 0;
612 }
613 }
614 if slice_start == i {
615 continue; // the cut landed exactly on the group's end
616 }
617 if slice_start > tile_start && tile_size + gtotal > budget {
618 tiles.push(make_tile(&triples[tile_start..slice_start]));
619 tile_start = slice_start;
620 tile_size = 0;
621 }
622 tile_size += gtotal;
623 prev_a = a;
624 }
625 if tile_start < triples.len() {
626 tiles.push(make_tile(&triples[tile_start..]));
627 }
628 tiles
629}
630
631/// The six tiled permutation sections, queryable by triple pattern.
632pub struct GraphIndex {
633 /// Tiles per permutation (SPO, SOP, PSO, POS, OSP, OPS — see [`ALL_PERMS`]),
634 /// ascending in their leading-id ranges; consecutive tiles may share a
635 /// leading id when a mega-group was split (see [`build_tiles`]).
636 pub(crate) sections: [Vec<Tile>; NUM_PERMS],
637 /// Which of those six slots this file actually carries. Sections outside
638 /// the set are empty and must never be consulted — an empty section is
639 /// indistinguishable from an empty *graph*, which is why the planner asks
640 /// this rather than the section lengths.
641 perms: PermSet,
642 /// Faults in remote tiles on first scan (`None` for local indexes).
643 loader: Option<TileLoader>,
644 /// Optional batched fetch for multi-tile scans (`None` falls back to
645 /// one [`TileLoader`] call per tile).
646 bulk: Option<TileBulkLoader>,
647 /// Set when the loader failed for some tile: results may be incomplete and
648 /// the caller must surface an error rather than the partial answer.
649 load_failed: std::sync::atomic::AtomicBool,
650 /// The reader's concurrent-range fan-out (1 = strictly sequential) — see
651 /// [`set_read_concurrency`](Self::set_read_concurrency).
652 read_concurrency: usize,
653 /// Decompressed tile bodies live here, keyed by `(section, tile)`, faulted
654 /// in on first scan and evictable. Phase 0 caps it at unlimited, so a
655 /// scanned permutation stays resident exactly as it did in the old per-tile
656 /// `OnceLock`s.
657 cache: Arc<ChunkCache>,
658}
659
660impl GraphIndex {
661 fn from_sections(mut sections: [Vec<Tile>; NUM_PERMS], perms: PermSet) -> Self {
662 // Move every locally-built/decoded tile body into the shared cache and
663 // clear its staging slot, so the descriptors hold routing metadata only.
664 let cache = ChunkCache::unlimited_arc();
665 for (si, tiles) in sections.iter_mut().enumerate() {
666 for (ti, tile) in tiles.iter_mut().enumerate() {
667 if let Some(body) = tile.staged.take() {
668 cache.insert((si as u8, ti as u32), Arc::from(body));
669 }
670 }
671 }
672 GraphIndex {
673 sections,
674 perms,
675 loader: None,
676 bulk: None,
677 load_failed: std::sync::atomic::AtomicBool::new(false),
678 read_concurrency: 1,
679 cache,
680 }
681 }
682
683 /// Rebuild from tiled sections: per permutation, `(min_a, max_a, block
684 /// bytes)` per tile in ascending leading-id order — the v0.2 layout.
685 /// Slots outside `perms` must be empty.
686 pub fn from_tiles(sections: [Vec<(u32, u32, Vec<u8>)>; NUM_PERMS], perms: PermSet) -> Self {
687 let sections = sections.map(|tiles| {
688 tiles
689 .into_iter()
690 .map(|(min_a, max_a, bytes)| Tile::local(min_a, max_a, bytes))
691 .collect()
692 });
693 Self::from_sections(sections, perms)
694 }
695
696 /// The permutations this index carries.
697 pub fn perms(&self) -> PermSet {
698 self.perms
699 }
700
701 /// Set the byte cap of this index's tile cache (bounded-export phase 2).
702 /// `u64::MAX` (the open-time default) is unlimited — a scanned permutation
703 /// stays resident exactly as before. A finite cap evicts least-recently-used
704 /// tiles, so a full scan's tile residency is bounded.
705 ///
706 /// Only safe on an index whose tiles can be **re-faulted** (a remote/ranged
707 /// index with a loader). A locally-built index has no loader — an evicted
708 /// tile could never be reloaded — so the caller must not cap those; the
709 /// bounded-export path caps only the ranged default-graph index and leaves
710 /// resident named-graph indexes unlimited (released per slot instead).
711 pub fn set_cache_cap(&self, cap: u64) {
712 self.cache.set_cap(cap);
713 }
714
715 /// The current tile-cache byte cap (`u64::MAX` = unlimited).
716 pub fn cache_cap(&self) -> u64 {
717 self.cache.cap()
718 }
719
720 /// Does this index fault its tiles through a loader (a remote/ranged open)?
721 /// Only such an index is safe to give a finite tile-cache cap: a locally
722 /// decoded index has no loader, so an evicted tile could never be reloaded.
723 pub fn is_lazy(&self) -> bool {
724 self.loader.is_some()
725 }
726
727 /// A snapshot of the tile cache's observability counters.
728 pub fn cache_stats(&self) -> crate::chunk_cache::CacheStats {
729 self.cache.stats()
730 }
731
732 /// Heap bytes this index holds for as long as it is open, outside its tile
733 /// cache: one descriptor per tile of every permutation the file carries
734 /// (plus a tile's group directory, if a bound-leading lookup built one),
735 /// and — on a ranged open — the two copies of the per-tile byte ranges the
736 /// loaders capture. Grows with the tile count, never with what a scan
737 /// touches.
738 pub fn directory_bytes(&self) -> u64 {
739 let tiles: u64 = self.sections.iter().map(|s| s.len() as u64).sum();
740 let caps: u64 = self
741 .sections
742 .iter()
743 .map(|s| s.capacity() as u64)
744 .sum::<u64>()
745 * std::mem::size_of::<Tile>() as u64;
746 let ranges = if self.loader.is_some() {
747 2 * 24 * tiles
748 } else {
749 0
750 };
751 caps + ranges
752 }
753
754 /// A **remote** index: only the tile directories (leading-id ranges per
755 /// permutation, ascending) are known; tile payloads fault in through
756 /// `loader` on first scan. Check [`load_incomplete`](Self::load_incomplete)
757 /// after evaluating — a failed fetch must become an error, never a
758 /// silently smaller result.
759 #[allow(clippy::type_complexity)]
760 pub fn from_remote_directories(
761 directories: [Vec<(u32, u32, Option<(u32, u32, u32, u32)>)>; NUM_PERMS],
762 perms: PermSet,
763 loader: TileLoader,
764 ) -> Self {
765 let sections = directories.map(|dir| {
766 dir.into_iter()
767 .map(|(min_a, max_a, syn)| Tile::remote(min_a, max_a, syn))
768 .collect()
769 });
770 GraphIndex {
771 sections,
772 perms,
773 loader: Some(loader),
774 bulk: None,
775 load_failed: std::sync::atomic::AtomicBool::new(false),
776 read_concurrency: 1,
777 cache: ChunkCache::unlimited_arc(),
778 }
779 }
780
781 /// Attach a batched tile fetcher (see [`TileBulkLoader`]): multi-tile
782 /// scans prefetch their span through it instead of faulting tile by tile.
783 pub fn with_bulk_loader(mut self, bulk: TileBulkLoader) -> Self {
784 self.bulk = Some(bulk);
785 self
786 }
787
788 /// Record each remote tile's encoded (on-disk) byte length, per section —
789 /// known to the ranged opener from the tile directory. Powers the join
790 /// planner's fatness gates; never triggers a fetch.
791 pub(crate) fn set_tile_lens(&mut self, lens: [Vec<u32>; NUM_PERMS]) {
792 for (section, ls) in self.sections.iter_mut().zip(lens) {
793 for (tile, l) in section.iter_mut().zip(ls) {
794 tile.len = l;
795 }
796 }
797 }
798
799 /// Record the reader's concurrent-range fan-out (see
800 /// [`RangeReader::concurrency`](crate::reader::RangeReader::concurrency)) —
801 /// the join planner widens its remote probe budget when round trips
802 /// overlap instead of serializing.
803 pub(crate) fn set_read_concurrency(&mut self, c: usize) {
804 self.read_concurrency = c.max(1);
805 }
806
807 /// The reader's concurrent-range fan-out (1 = strictly sequential).
808 pub(crate) fn read_concurrency(&self) -> usize {
809 self.read_concurrency
810 }
811
812 /// Did any tile fetch fail since this index was opened — or since the last
813 /// [`reset_load_failure`](Self::reset_load_failure)?
814 pub fn load_incomplete(&self) -> bool {
815 self.load_failed.load(std::sync::atomic::Ordering::Relaxed)
816 }
817
818 /// Forget recorded fetch failures — the start-of-evaluation reset for a
819 /// RESIDENT session, making the incompleteness verdict per-query instead of
820 /// per-open (one transient network blip used to fail every later query on
821 /// the session). Safe because failed tiles are never cached: the next scan
822 /// simply retries them.
823 pub fn reset_load_failure(&self) {
824 self.load_failed
825 .store(false, std::sync::atomic::Ordering::Relaxed);
826 }
827
828 /// True for a remote/lazy index (tiles fault in over a `RangeReader`). The
829 /// join planner uses this to pick read-aware strategies: here a per-row index
830 /// probe is a network round-trip, not a memory lookup, so a left-deep scan +
831 /// hash join often beats probing a moderately-sized prefix.
832 pub fn is_remote(&self) -> bool {
833 self.loader.is_some()
834 }
835
836 /// Batch-fault the unloaded tiles in `[start, end)` of `section` through
837 /// the bulk loader, if one is attached and at least two tiles are missing
838 /// (a single missing tile costs the same either way). A failed batch is
839 /// not an error here: the tiles stay unloaded and the per-tile loader
840 /// retries each one (recording failures) when the scan reaches it.
841 fn prefetch_span(&self, section: usize, start: usize, end: usize) {
842 let missing: Vec<usize> = (start..end)
843 .filter(|&ti| !self.tile_resident(section, ti))
844 .collect();
845 self.bulk_fault(section, &missing);
846 }
847
848 /// Is tile `(section, tile)`'s body currently resident in the cache?
849 fn tile_resident(&self, section: usize, tile: usize) -> bool {
850 self.cache.contains((section as u8, tile as u32))
851 }
852
853 /// Bulk-fault a set of (possibly scattered, ascending) missing tile indices in
854 /// `section` through the bulk loader in one coalesced read, storing each image.
855 /// No-op without a bulk loader or for fewer than two tiles (a single fault
856 /// costs the same via the per-tile loader). A failed batch leaves the tiles
857 /// unloaded for the per-tile loader to retry. Shared by the consecutive-span
858 /// scan prefetch and the scattered batch-probe prefetch.
859 fn bulk_fault(&self, section: usize, tiles: &[usize]) {
860 if tiles.len() < 2 {
861 return;
862 }
863 let Some(bulk) = &self.bulk else { return };
864 if let Some(images) = bulk(section, tiles) {
865 if images.len() == tiles.len() {
866 for (&ti, img) in tiles.iter().zip(images) {
867 self.cache
868 .insert((section as u8, ti as u32), Arc::from(img));
869 }
870 }
871 }
872 }
873
874 /// Batch-fault the tiles a set of upcoming **probe** patterns will route to,
875 /// before they are probed one at a time. Each probe (a bound leading id)
876 /// routes to a single tile; gathered across the batch those tiles are
877 /// scattered, so faulting them together turns N sequential remote round trips
878 /// into a few coalesced parallel reads — the read-amplification win for a
879 /// label-heavy join over a lazy reader. Honors the synopsis prune (never
880 /// fetches a tile a bound secondary proves can't match). No-op for a local
881 /// index or when fewer than two tiles per section need faulting.
882 pub(crate) fn prefetch_probe_tiles(&self, patterns: &[Pattern]) {
883 if self.bulk.is_none() {
884 return;
885 }
886 let mut want: [std::collections::BTreeSet<usize>; NUM_PERMS] = Default::default();
887 for &pat in patterns {
888 let perm = Self::best_permutation(pat);
889 let [pa, pb, pc] = perm.order_pattern(pat);
890 let si = perm.section_index();
891 let (start, end) = self.tile_span(si, pa);
892 for ti in start..end {
893 if self.sections[si][ti].syn_admits(pb, pc) && !self.tile_resident(si, ti) {
894 want[si].insert(ti);
895 }
896 }
897 }
898 for (si, set) in want.iter().enumerate() {
899 let tiles: Vec<usize> = set.iter().copied().collect();
900 self.bulk_fault(si, &tiles);
901 }
902 }
903
904 /// The tile's cache entry, faulting its block image in through the loader if
905 /// remote. Returns an owned [`CacheEntry`] handle (an `Arc`) the caller
906 /// holds for the duration of one parse/scan — valid even if a later eviction
907 /// drops the map slot (the handout invariant).
908 ///
909 /// A FAILED fetch records the failure and caches nothing (returns `None`),
910 /// so a later evaluation retries the tile — a transient network error must
911 /// not permanently poison a long-lived (resident) session. A local tile
912 /// with no body returns an empty entry (constructed empty on purpose).
913 fn tile_entry(&self, section: usize, tile: usize) -> Option<Arc<CacheEntry>> {
914 let key = (section as u8, tile as u32);
915 if let Some(e) = self.cache.get(key) {
916 return Some(e);
917 }
918 match &self.loader {
919 Some(load) => match load(section, tile) {
920 Some(bytes) => Some(self.cache.insert(key, Arc::from(bytes))),
921 None => {
922 self.load_failed
923 .store(true, std::sync::atomic::Ordering::Relaxed);
924 None
925 }
926 },
927 None => Some(self.cache.insert(key, Arc::from(Vec::new()))),
928 }
929 }
930
931 /// Tile `(section, tile)`'s a-group directory, held in its cache entry and
932 /// built from `block` (that entry's parsed body) on first use. Its bytes
933 /// are charged to the tile cache when built, so a capped cache bounds the
934 /// directories too, and they are evicted with their tile.
935 fn tile_dir<'e>(
936 &self,
937 section: usize,
938 tile: usize,
939 entry: &'e Arc<CacheEntry>,
940 block: &TripleBlock<'_>,
941 ) -> &'e GroupDirectory {
942 let (dir, built) = entry.group_dir(|| block.group_directory());
943 if built {
944 self.cache.charge_extra(
945 (section as u8, tile as u32),
946 entry,
947 dir.heap_bytes() + std::mem::size_of::<GroupDirectory>() as u64,
948 );
949 }
950 dir
951 }
952
953 /// The tile's decompressed block image as a shared handle, for the file
954 /// writer (local indexes, all tiles resident). Empty on a miss.
955 pub(crate) fn tile_body(&self, section: usize, tile: usize) -> Arc<[u8]> {
956 self.tile_entry(section, tile)
957 .map(|e| e.body_arc())
958 .unwrap_or_else(|| Arc::from(Vec::new()))
959 }
960
961 /// Total triple count (sum of the SPO tiles' zone counts). For a remote
962 /// index this faults in the SPO tiles — prefer the header's quad count.
963 pub fn triple_count(&self) -> u32 {
964 self.prefetch_span(0, 0, self.sections[0].len());
965 (0..self.sections[0].len())
966 .filter_map(|ti| {
967 let entry = self.tile_entry(0, ti)?;
968 TripleBlock::parse(entry.body())
969 .ok()
970 .map(|b| b.zone().count)
971 })
972 .sum()
973 }
974
975 /// Stream **every** triple of one stored permutation in that section's own
976 /// order, as `(a, b, c)` in the permutation's column order (SPO gives
977 /// `(s, p, o)`, OSP gives `(o, s, p)`, …).
978 ///
979 /// The access a whole-graph pass needs — `rete recard`'s streaming card
980 /// derivation reads SPO for out-degrees and OSP for in-degrees and literal
981 /// objects — and one [`scan_iter`](Self::scan_iter) cannot give, because a
982 /// fully unbound pattern always routes to SPO. Tiles fault with the same
983 /// geometric prefetch as a scan and are decoded one at a time; nothing is
984 /// retained beyond the tile cache, so memory follows its cap, not the
985 /// section. A failed fault is recorded in
986 /// [`load_incomplete`](Self::load_incomplete) and the tile is skipped, as
987 /// everywhere else. Returns `false` when the file does not store `perm`.
988 pub fn for_each_in_order(
989 &self,
990 perm: IndexPermutation,
991 f: &mut dyn FnMut(u32, u32, u32),
992 ) -> bool {
993 if !self.perms.contains(perm) {
994 return false;
995 }
996 let si = perm.section_index();
997 let end = self.sections[si].len();
998 let mut window = PREFETCH_WINDOW_START;
999 for ti in 0..end {
1000 if !self.tile_resident(si, ti) {
1001 self.prefetch_span(si, ti, (ti + window).min(end));
1002 window = window.saturating_mul(2).min(PREFETCH_WINDOW_MAX);
1003 }
1004 if let Some(entry) = self.tile_entry(si, ti) {
1005 if let Ok(block) = TripleBlock::parse(entry.body()) {
1006 for (a, b, c) in block.scan(None, None, None) {
1007 f(a, b, c);
1008 }
1009 }
1010 }
1011 }
1012 true
1013 }
1014
1015 /// The tiles of each permutation section (in `ALL_PERMS` order), for the
1016 /// file writer.
1017 pub fn tile_sections(&self) -> [&[Tile]; NUM_PERMS] {
1018 [
1019 &self.sections[0],
1020 &self.sections[1],
1021 &self.sections[2],
1022 &self.sections[3],
1023 &self.sections[4],
1024 &self.sections[5],
1025 ]
1026 }
1027
1028 /// The permutation selected for a pattern: the one with the longest bound
1029 /// prefix. Ties keep the canonical SPO order (then POS, then OSP), which
1030 /// makes provenance stable for unbound or equally selective shapes and routes
1031 /// a fully unbound pattern to the SPO block rather than fetching all three.
1032 ///
1033 /// Six-permutation shorthand for [`best_permutation_in`]. Its answer is
1034 /// always inside [`PermSet::CORE`] (see `perm_routing_never_leaves_core`),
1035 /// so a file with any legal [`PermSet`] routes identically — but call the
1036 /// `_in` form wherever the file's set is at hand, so that stays true by
1037 /// construction rather than by argument.
1038 ///
1039 /// [`best_permutation_in`]: GraphIndex::best_permutation_in
1040 pub fn best_permutation(pattern: Pattern) -> IndexPermutation {
1041 Self::best_permutation_in(PermSet::ALL, pattern)
1042 }
1043
1044 /// [`best_permutation`](GraphIndex::best_permutation) restricted to the
1045 /// permutations a file carries.
1046 pub fn best_permutation_in(perms: PermSet, pattern: Pattern) -> IndexPermutation {
1047 let mut best = IndexPermutation::Spo;
1048 let mut best_score = best.leading_bound(pattern);
1049 for perm in perms.iter() {
1050 let score = perm.leading_bound(pattern);
1051 if score > best_score {
1052 best = perm;
1053 best_score = score;
1054 }
1055 }
1056 best
1057 }
1058
1059 /// Choose a permutation that **routes** on `pattern`'s bound prefix *and*
1060 /// streams sorted on the canonical column `sort_col` (0=subject, 1=predicate,
1061 /// 2=object) — i.e. `sort_col` is the leading *free* component after the bound
1062 /// prefix. Returns `None` if `sort_col` is itself bound (then every row shares
1063 /// that value — any permutation is "sorted" on it) or no permutation qualifies.
1064 /// This is the precondition a merge join needs: both inputs sorted on the join
1065 /// key. Among qualifiers, the longest bound prefix (best routing) wins.
1066 ///
1067 /// Six-permutation shorthand for [`permutation_sorted_on_in`].
1068 ///
1069 /// [`permutation_sorted_on_in`]: GraphIndex::permutation_sorted_on_in
1070 pub fn permutation_sorted_on(pattern: Pattern, sort_col: usize) -> Option<IndexPermutation> {
1071 Self::permutation_sorted_on_in(PermSet::ALL, pattern, sort_col)
1072 }
1073
1074 /// [`permutation_sorted_on`](GraphIndex::permutation_sorted_on) restricted
1075 /// to the permutations a file carries — **and it will not trade routing for
1076 /// sort order**.
1077 ///
1078 /// A qualifier must match the bound prefix that
1079 /// [`best_permutation_in`](GraphIndex::best_permutation_in) achieves.
1080 /// With all six that costs nothing: every ordering of `(s, p, o)` exists, so
1081 /// "the bound components first, then `sort_col`" is always available at the
1082 /// maximal prefix. With [`PermSet::CORE`] it is what stops a merge join from
1083 /// buying its co-sorted stream with a **whole-section scan** — e.g.
1084 /// `?s :p ?o` sorted on `?s` has no `P*S` order left, and the only remaining
1085 /// sorted-on-subject stream is an unrouted SPO scan of the entire graph. The
1086 /// merge seed then declines and the hash/probe path answers, which is
1087 /// strictly the cheaper plan.
1088 pub fn permutation_sorted_on_in(
1089 perms: PermSet,
1090 pattern: Pattern,
1091 sort_col: usize,
1092 ) -> Option<IndexPermutation> {
1093 let bound = [
1094 pattern.0.is_some(),
1095 pattern.1.is_some(),
1096 pattern.2.is_some(),
1097 ];
1098 if bound[sort_col] {
1099 return None;
1100 }
1101 let routed = Self::best_permutation_in(perms, pattern).leading_bound(pattern);
1102 let mut best: Option<(IndexPermutation, usize)> = None;
1103 for perm in perms.iter() {
1104 let roles = perm.roles();
1105 let lead = perm.leading_bound(pattern);
1106 // The component at slot `lead` is the first free one; it must be the
1107 // sort column (so the stream is sorted on it after the bound prefix).
1108 if lead < 3 && roles[lead] == sort_col && best.map(|(_, s)| lead > s).unwrap_or(true) {
1109 best = Some((perm, lead));
1110 }
1111 }
1112 best.filter(|&(_, lead)| lead >= routed).map(|(p, _)| p)
1113 }
1114
1115 /// Match one already-decoded serialized permutation block (a single v0.1
1116 /// section or one v0.2 tile). This is the core primitive for range-routed
1117 /// readers: the caller fetches only the selected payload, then this scans
1118 /// it as if it came from a full [`GraphIndex`].
1119 pub fn match_serialized_block(
1120 bytes: &[u8],
1121 permutation: IndexPermutation,
1122 pattern: Pattern,
1123 ) -> Vec<Triple> {
1124 let [pa, pb, pc] = permutation.order_pattern(pattern);
1125 let mut out: Vec<Triple> = TripleBlock::parse(bytes)
1126 .ok()
1127 .filter(|b| b.zone().may_contain(pa, pb, pc))
1128 .map(|b| b.scan(pa, pb, pc))
1129 .into_iter()
1130 .flatten()
1131 .map(move |abc| permutation.back(abc))
1132 .collect();
1133 out.sort_unstable();
1134 out
1135 }
1136
1137 /// All triples matching `pattern`, returned in canonical `(s, p, o)` order.
1138 ///
1139 /// Thin eager wrapper over [`scan_iter`](Self::scan_iter): collect the lazy
1140 /// stream and restore the canonical sort (the stream is sorted in the chosen
1141 /// permutation's order, which differs from canonical once `perm.back`
1142 /// permutes the free components).
1143 pub fn match_pattern(&self, pattern: Pattern) -> Vec<Triple> {
1144 let mut out: Vec<Triple> = self.scan_iter(pattern).collect();
1145 out.sort_unstable();
1146 out
1147 }
1148
1149 /// Lazily stream the triples matching `pattern` in canonical `(s, p, o)`
1150 /// order *within the chosen permutation* — the streaming entry point for
1151 /// callers that can stop early (ASK, `LIMIT`, BGP probes) or that don't need
1152 /// the canonical re-sort. Decodes only the matching groups (see
1153 /// [`TripleBlock::scan`]); a malformed/absent block yields nothing rather
1154 /// than panicking. The permutation is chosen for the longest bound prefix.
1155 pub fn scan_iter(&self, pattern: Pattern) -> impl Iterator<Item = Triple> + '_ {
1156 self.scan_iter_with(pattern, Self::best_permutation(pattern))
1157 }
1158
1159 /// What [`scan_iter`](Self::scan_iter) *would* fetch for `pattern`, computed
1160 /// from the tile directories alone — **no tile is fetched, decoded, or even
1161 /// looked at**. This is the cost-preview half of the routed scan: it walks
1162 /// the same three decisions the scan makes (permutation, routed tile span,
1163 /// synopsis prune) and reports what survives, so a caller can say "this dump
1164 /// will pull 16 MB of index, not 376 MB" before starting it.
1165 ///
1166 /// It is a preview, not a promise: `tile_bytes` is an **upper bound** on the
1167 /// index bytes the scan reads (an admitted tile can still be rejected by its
1168 /// in-tile zone map once in hand, and a re-scan of an already-faulted tile
1169 /// reads nothing), and it says nothing about the dictionary, which is where
1170 /// a term-resolving dump's real cost usually lives.
1171 pub fn scan_plan(&self, pattern: Pattern) -> ScanPlan {
1172 let perm = Self::best_permutation_in(self.perms, pattern);
1173 let [_, pb, pc] = perm.order_pattern(pattern);
1174 let si = perm.section_index();
1175 let tiles = &self.sections[si];
1176 let (start, end) = self.tile_span(si, perm.order_pattern(pattern)[0]);
1177 let mut admitted = 0usize;
1178 let mut tile_bytes = 0u64;
1179 for t in &tiles[start..end] {
1180 if t.syn_admits(pb, pc) {
1181 admitted += 1;
1182 tile_bytes += t.encoded_len();
1183 }
1184 }
1185 ScanPlan {
1186 permutation: perm,
1187 tiles_total: tiles.len(),
1188 tiles_routed: end - start,
1189 tiles_admitted: admitted,
1190 tile_bytes,
1191 section_bytes: tiles.iter().map(|t| t.encoded_len()).sum(),
1192 }
1193 }
1194
1195 /// One bounded, **resumable** slice of `pattern`'s matches: the pull form of
1196 /// [`scan_iter`](Self::scan_iter), for a caller that cannot hold a borrow
1197 /// across calls.
1198 ///
1199 /// Returns `(triples, next_cursor, done)`; start at `cursor = 0` and feed the
1200 /// returned cursor back until `done`. The whole resume state is one opaque
1201 /// `u64` — `(tile index, next a-group id)` — so no iterator, no thread and no
1202 /// self-referential struct has to survive between calls. That is the shape a
1203 /// foreign-function boundary needs: a wasm module cannot suspend a Rust
1204 /// iterator mid-scan and hand control back to its host, but it can be handed
1205 /// a `u64` and told to carry on.
1206 ///
1207 /// `max_rows` is a **floor**, not a hard cut: a batch always ends on an
1208 /// a-group boundary of the chosen permutation, so no group is ever split
1209 /// across two calls and the overshoot is one group's fanout. Nothing is ever
1210 /// rescanned ([`TripleBlock::scan_resume`] jumps straight to the group
1211 /// through the tile's directory), so a full drain is O(n) overall — unlike
1212 /// `(offset, limit)`, which is O(n²/limit).
1213 ///
1214 /// Every call either yields at least one row or reports `done`, so a caller
1215 /// never has to guard against an empty non-final batch.
1216 ///
1217 /// **Order.** Rows arrive in the chosen permutation's order and each batch is
1218 /// re-sorted canonically, exactly as [`match_pattern`](Self::match_pattern)
1219 /// does for a whole result. For a fully unbound pattern (routed to SPO) that
1220 /// makes the concatenation of every batch identical to `match_pattern`'s
1221 /// output; for a bound pattern the *set* is identical but the canonical
1222 /// re-sort is per batch rather than global.
1223 ///
1224 /// **Memory** is O(`max_rows` + one a-group + the tiles faulted so far) —
1225 /// never O(matches). That is the whole point: an unbounded `(?s ?p ?o)` over
1226 /// a 26-million-quad graph used to materialize every match before the caller
1227 /// saw one row.
1228 pub fn scan_batch(
1229 &self,
1230 pattern: Pattern,
1231 cursor: u64,
1232 max_rows: usize,
1233 ) -> (Vec<Triple>, u64, bool) {
1234 let max_rows = max_rows.max(1);
1235 let perm = Self::best_permutation(pattern);
1236 let [pa, pb, pc] = perm.order_pattern(pattern);
1237 let si = perm.section_index();
1238 // Same routing as `scan_iter_with`: the tile directory alone decides the
1239 // span, so this is free on a lazy/remote open.
1240 let (start, end) = self.tile_span(si, pa);
1241 let mut ti = ((cursor >> 32) as usize).max(start);
1242 let mut from_a = cursor as u32;
1243 let mut out: Vec<Triple> = Vec::new();
1244 // Same geometric prefetch ramp as the streaming scan: a batch that stops
1245 // after one tile never fetches the ones behind it.
1246 let mut window = PREFETCH_WINDOW_START;
1247 while ti < end {
1248 // Synopsis pre-fault prune — no tile is fetched to reject it.
1249 if !self.sections[si][ti].syn_admits(pb, pc) {
1250 ti += 1;
1251 from_a = 0;
1252 continue;
1253 }
1254 if !self.tile_resident(si, ti) {
1255 self.prefetch_span(si, ti, (ti + window).min(end));
1256 window = window.saturating_mul(2).min(PREFETCH_WINDOW_MAX);
1257 }
1258 let entry = self.tile_entry(si, ti);
1259 let mut resume_at: Option<u32> = None;
1260 if let Some(block) = entry
1261 .as_deref()
1262 .and_then(|e| TripleBlock::parse(e.body()).ok())
1263 .filter(|b| b.zone().may_contain(pa, pb, pc))
1264 {
1265 let rows = match pa {
1266 // A bound leading id is one a-group; it cannot be split
1267 // inside a tile, only across tiles (a mega-group split), so
1268 // there is nothing finer to resume at than the tile.
1269 Some(a) => {
1270 let dir = self.tile_dir(si, ti, entry.as_ref().unwrap(), &block);
1271 block.scan_from(dir, a, pb, pc)
1272 }
1273 // Fresh tile: no directory needed, so a straight drain never
1274 // pays for one.
1275 None if from_a == 0 => block.scan(None, pb, pc),
1276 None => {
1277 let dir = self.tile_dir(si, ti, entry.as_ref().unwrap(), &block);
1278 block.scan_resume(dir, from_a, pb, pc)
1279 }
1280 };
1281 // Only a leading-unbound scan can be resumed mid-tile: with `pa`
1282 // bound there is one a-group and `scan_from` always restarts it
1283 // from the top, so cutting inside it would duplicate rows.
1284 let splittable = pa.is_none();
1285 let mut last_a: Option<u32> = None;
1286 for abc in rows {
1287 // Full, and this row opens a new a-group ⇒ cut here and
1288 // resume at this group. The row is dropped, not lost: the
1289 // next call re-enters at exactly this id.
1290 if splittable && out.len() >= max_rows && last_a != Some(abc.0) {
1291 resume_at = Some(abc.0);
1292 break;
1293 }
1294 last_a = Some(abc.0);
1295 out.push(perm.back(abc));
1296 }
1297 }
1298 if let Some(a) = resume_at {
1299 out.sort_unstable();
1300 return (out, ((ti as u64) << 32) | a as u64, false);
1301 }
1302 ti += 1;
1303 from_a = 0;
1304 if out.len() >= max_rows {
1305 break;
1306 }
1307 }
1308 let done = ti >= end;
1309 out.sort_unstable();
1310 (out, (ti as u64) << 32, done)
1311 }
1312
1313 /// Stream `pattern`'s matches **sorted on the canonical column `sort_col`**
1314 /// (0=subject, 1=predicate, 2=object): the stream's `sort_col` values are
1315 /// ascending. Chooses a permutation that routes on the bound prefix *and*
1316 /// orders by `sort_col` ([`permutation_sorted_on`](Self::permutation_sorted_on));
1317 /// `None` when none qualifies (e.g. `sort_col` is itself bound). The
1318 /// precondition for feeding a [merge join](crate::bgp): both inputs sorted on
1319 /// the shared join key.
1320 pub(crate) fn scan_iter_sorted_on(
1321 &self,
1322 pattern: Pattern,
1323 sort_col: usize,
1324 ) -> Option<impl Iterator<Item = Triple> + '_> {
1325 Some(self.scan_iter_with(
1326 pattern,
1327 Self::permutation_sorted_on_in(self.perms, pattern, sort_col)?,
1328 ))
1329 }
1330
1331 /// Stream `pattern`'s matches using a **specific** permutation `perm`; the
1332 /// stream is sorted in `perm`'s `(a, b, c)` order. Shared core of `scan_iter`
1333 /// and `scan_iter_sorted_on`.
1334 fn scan_iter_with(
1335 &self,
1336 pattern: Pattern,
1337 perm: IndexPermutation,
1338 ) -> impl Iterator<Item = Triple> + '_ {
1339 // Route: a bound leading id binary-searches the tile directory to exactly
1340 // one tile (groups are never split across tiles); an unbound one chains
1341 // every tile's cursor. Within a tile, a bound leading scan jumps to its
1342 // a-group via the tile's lazily-built group directory — built on first
1343 // use, costing one walk of that (budget-sized) tile.
1344 let [pa, pb, pc] = perm.order_pattern(pattern);
1345 let si = perm.section_index();
1346 let (start, end) = self.tile_span(si, pa);
1347 // Coalesce tile faults, but ramp the prefetch window geometrically as
1348 // the scan advances rather than fetching the whole span up front: a
1349 // small `LIMIT` (which stops pulling early) then never faults tiles
1350 // past the rows it needs, while a full scan still batches into a
1351 // handful of coalesced reads (4, 8, 16, … tiles). A bound leading scan
1352 // spans a single tile, so the prefetch no-ops and it faults just that
1353 // one tile, unchanged.
1354 let window = std::cell::Cell::new(PREFETCH_WINDOW_START);
1355 (start..end)
1356 // Synopsis pre-fault prune: drop a routed tile the directory proves
1357 // can't match a bound secondary component, **without fetching it**
1358 // (the remote win — a negative/sparse lookup costs zero tile reads).
1359 // A bound leading id routes to a single tile, so this is where it
1360 // bites; a fully-unbound leading scan leaves `pb`/`pc` unbound, so
1361 // `syn_admits` keeps every tile, unchanged.
1362 .filter(move |&ti| self.sections[si][ti].syn_admits(pb, pc))
1363 .flat_map(move |ti| {
1364 // Fault in (if remote), parse (untrusted bytes ⇒ `None` on
1365 // malformed), and zone-prune per tile, then stream the
1366 // matching groups.
1367 if !self.tile_resident(si, ti) {
1368 let w = window.get();
1369 self.prefetch_span(si, ti, (ti + w).min(end));
1370 window.set(w.saturating_mul(2).min(PREFETCH_WINDOW_MAX));
1371 }
1372 // Decode the tile once and materialize its matching rows into an
1373 // owned `Vec`, so the borrowed block/cursor never outlives the
1374 // tile's cache handle (an `Arc` dropped at the end of this
1375 // closure). The buffer is bounded to one tile's rows; the scan
1376 // stays lazy *across* tiles, so a `LIMIT` still stops early.
1377 let rows: Vec<Triple> = match self.tile_entry(si, ti) {
1378 Some(entry) => TripleBlock::parse(entry.body())
1379 .ok()
1380 .filter(|b| b.zone().may_contain(pa, pb, pc))
1381 .map(|b| match pa {
1382 Some(a) => {
1383 let dir = self.tile_dir(si, ti, &entry, &b);
1384 b.scan_from(dir, a, pb, pc).collect()
1385 }
1386 None => b.scan(pa, pb, pc).collect(),
1387 })
1388 .unwrap_or_default(),
1389 None => Vec::new(),
1390 };
1391 rows.into_iter()
1392 })
1393 .map(move |abc| perm.back(abc))
1394 }
1395
1396 /// A planner estimate of how many triples match `pattern`, read off the
1397 /// tile directories alone — no tile is fetched or decoded, so it costs the
1398 /// same on a lazy 50 GB file as on a resident one. `total` is the number
1399 /// of triples the index holds (an upper bound is fine).
1400 ///
1401 /// For every carried permutation that leads with a bound component, the
1402 /// routed tiles the synopsis admits are counted; a tile holds about
1403 /// `total / tiles` triples, and the routed run is charged half a tile less
1404 /// than its length, since a key rarely starts and ends on tile boundaries.
1405 /// The smallest figure wins, so `?s a :Rare` (a few tiles of OPS) beats
1406 /// `?s :p ?o` (every tile of `:p` in PSO). Its resolution is one tile:
1407 /// patterns inside one tile tie, which is all a join order needs. A
1408 /// pattern with no bound component estimates `total`; one the synopsis
1409 /// rules out estimates 0.
1410 pub(crate) fn estimate_matches(&self, pattern: Pattern, total: u64) -> f64 {
1411 let mut best = total as f64;
1412 for perm in self.perms.iter() {
1413 let [pa, pb, pc] = perm.order_pattern(pattern);
1414 if pa.is_none() {
1415 continue;
1416 }
1417 let si = perm.section_index();
1418 let tiles = &self.sections[si];
1419 if tiles.is_empty() {
1420 return 0.0;
1421 }
1422 let per_tile = total as f64 / tiles.len() as f64;
1423 let (start, end) = self.tile_span(si, pa);
1424 let admitted = tiles[start..end]
1425 .iter()
1426 .filter(|t| t.syn_admits(pb, pc))
1427 .count();
1428 let est = if admitted == 0 {
1429 0.0
1430 } else {
1431 (admitted as f64 - 0.5) * per_tile
1432 };
1433 best = best.min(est);
1434 }
1435 best
1436 }
1437
1438 /// The smallest leading id `>= a` present in permutation section `section`,
1439 /// or `None` past the last one. Routes by the tile directory, then reads the
1440 /// tile's own group directory — so it skips the ids a tile's *range* covers
1441 /// but its body does not hold. That matters where a range is conservative:
1442 /// a quad-index view's tile shared with other graphs is `0..=u32::MAX`, and
1443 /// stepping through it id by id would never finish. Faults the tiles it
1444 /// reads; a failed fault ends the search (`load_incomplete` says so).
1445 pub(crate) fn next_leading(&self, section: usize, a: u32) -> Option<u32> {
1446 let tiles = &self.sections[section];
1447 let mut ti = tiles.partition_point(|t| t.max_a < a);
1448 while ti < tiles.len() {
1449 let from = a.max(tiles[ti].min_a);
1450 let entry = self.tile_entry(section, ti)?;
1451 if let Ok(block) = TripleBlock::parse(entry.body()) {
1452 let dir = self.tile_dir(section, ti, &entry, &block);
1453 if let Some(x) = dir.first_at_or_after(from) {
1454 return Some(x);
1455 }
1456 }
1457 ti += 1;
1458 }
1459 None
1460 }
1461
1462 /// The tile index span a scan must visit: every tile when the leading
1463 /// component is unbound, else the run of tiles whose leading-id ranges
1464 /// cover it. Tile ranges are ascending; consecutive tiles may SHARE a
1465 /// leading id when a mega-group was split across tiles (see
1466 /// [`build_tiles`]), so the span is a range scan, not a single hit —
1467 /// files without splits still yield a span of at most one tile.
1468 pub(crate) fn tile_span(&self, section: usize, pa: Option<u32>) -> (usize, usize) {
1469 let tiles = &self.sections[section];
1470 match pa {
1471 None => (0, tiles.len()),
1472 Some(a) => {
1473 let i = tiles.partition_point(|t| t.max_a < a);
1474 let mut j = i;
1475 while j < tiles.len() && tiles[j].min_a <= a {
1476 j += 1;
1477 }
1478 (i, j)
1479 }
1480 }
1481 }
1482}
1483
1484#[cfg(test)]
1485mod tests {
1486 use super::*;
1487
1488 fn graph() -> (GraphIndex, Vec<Triple>) {
1489 let data = vec![
1490 (1, 10, 100),
1491 (1, 10, 101),
1492 (1, 11, 100),
1493 (2, 10, 100),
1494 (2, 12, 200),
1495 (3, 11, 300),
1496 ];
1497 let mut b = GraphIndexBuilder::new();
1498 for &t in &data {
1499 b.push(t);
1500 }
1501 (b.build(), data)
1502 }
1503
1504 /// Brute-force reference for a pattern.
1505 fn reference(data: &[Triple], (s, p, o): Pattern) -> Vec<Triple> {
1506 let mut v: Vec<Triple> = data
1507 .iter()
1508 .copied()
1509 .filter(|&(a, b, c)| {
1510 s.is_none_or(|x| x == a) && p.is_none_or(|x| x == b) && o.is_none_or(|x| x == c)
1511 })
1512 .collect();
1513 v.sort_unstable();
1514 v
1515 }
1516
1517 /// The tile-directory estimate orders patterns the way their true sizes
1518 /// do, never fetches anything, and is exact at its extremes: unbound is the
1519 /// total, a key no tile can hold is zero.
1520 #[test]
1521 fn tile_estimates_rank_patterns_by_size() {
1522 // Predicate 1 on every subject, predicate 2 on a few; object 50 is a
1523 // "class" with 4000 instances, object 60 one with 3.
1524 let mut b = GraphIndexBuilder::new().with_tile_budget(256);
1525 let mut n = 0u64;
1526 for s in 0..4000u32 {
1527 b.push((s, 1, 50));
1528 b.push((s, 3, 1000 + s));
1529 n += 2;
1530 if s % 1000 == 0 {
1531 b.push((s, 2, 60));
1532 n += 1;
1533 }
1534 }
1535 let idx = b.build();
1536 let est = |p: Pattern| idx.estimate_matches(p, n);
1537 assert_eq!(est((None, None, None)), n as f64);
1538 assert!(est((None, Some(2), None)) < est((None, Some(1), None)));
1539 assert!(est((None, None, Some(60))) < est((None, None, Some(50))));
1540 assert!(est((Some(7), None, None)) < est((None, Some(3), None)));
1541 // A big predicate spans many tiles; the rare one fits in at most one.
1542 let per_tile = n as f64 / idx.tile_sections()[0].len() as f64;
1543 assert!(est((None, Some(2), None)) <= 1.5 * per_tile);
1544 assert!(est((None, Some(1), None)) > 10.0 * per_tile);
1545 // A leading id beyond every tile's range is provably absent.
1546 assert_eq!(est((Some(99_999), None, None)), 0.0);
1547 // An empty index estimates nothing for a bound pattern.
1548 let empty = GraphIndexBuilder::new().build();
1549 assert_eq!(empty.estimate_matches((Some(1), None, None), 0), 0.0);
1550 }
1551
1552 #[test]
1553 fn every_pattern_shape_matches_reference() {
1554 let (idx, data) = graph();
1555 let vals = |opts: &[u32]| {
1556 let mut v: Vec<Option<u32>> = opts.iter().map(|&x| Some(x)).collect();
1557 v.push(None);
1558 v
1559 };
1560 // Exercise all 8 bound/unbound shapes across representative values.
1561 for s in vals(&[1, 2, 9]) {
1562 for p in vals(&[10, 11, 99]) {
1563 for o in vals(&[100, 300, 999]) {
1564 let pat = (s, p, o);
1565 assert_eq!(
1566 idx.match_pattern(pat),
1567 reference(&data, pat),
1568 "pattern {pat:?}"
1569 );
1570 // The lazy stream must match the eager result once sorted —
1571 // same triples, just without the up-front canonical re-sort.
1572 let mut streamed: Vec<Triple> = idx.scan_iter(pat).collect();
1573 streamed.sort_unstable();
1574 assert_eq!(streamed, reference(&data, pat), "scan_iter {pat:?}");
1575 }
1576 }
1577 }
1578 }
1579
1580 #[test]
1581 fn unbound_returns_everything_sorted() {
1582 let (idx, data) = graph();
1583 let mut sorted = data.clone();
1584 sorted.sort_unstable();
1585 assert_eq!(idx.match_pattern((None, None, None)), sorted);
1586 }
1587
1588 /// A tiny tile budget must split sections into many tiles — including
1589 /// MID-GROUP cuts (budget 1/16 makes every a-group oversized) — and every
1590 /// pattern shape must still match the brute-force reference: bound
1591 /// leading ids route to the run of covering tiles, unbound scans chain
1592 /// all tiles.
1593 #[test]
1594 fn multi_tile_sections_match_reference_every_shape() {
1595 // Enough distinct leading ids to split under a tiny budget.
1596 let mut data: Vec<Triple> = Vec::new();
1597 for s in 1..=40u32 {
1598 for p in [10u32, 11] {
1599 for o in [100u32, 100 + s] {
1600 data.push((s, p, o));
1601 }
1602 }
1603 }
1604 for budget in [1usize, 16, 64, 1 << 20] {
1605 let mut b = GraphIndexBuilder::new().with_tile_budget(budget);
1606 for &t in &data {
1607 b.push(t);
1608 }
1609 let idx = b.build();
1610 let spo_tiles = idx.tile_sections()[0].len();
1611 if budget <= 16 {
1612 assert!(spo_tiles > 1, "budget {budget} should force tiling");
1613 }
1614 // Tile ranges must be ascending; a split mega-group may leave
1615 // consecutive tiles SHARING a leading id (never overlapping past it).
1616 for w in idx.tile_sections()[0].windows(2) {
1617 assert!(w[0].leading_range().1 <= w[1].leading_range().0);
1618 }
1619 assert_eq!(idx.triple_count() as usize, data.len(), "budget {budget}");
1620
1621 let vals = |opts: &[u32]| {
1622 let mut v: Vec<Option<u32>> = opts.iter().map(|&x| Some(x)).collect();
1623 v.push(None);
1624 v
1625 };
1626 for _round in 0..2 {
1627 for s in vals(&[1, 20, 40, 99]) {
1628 for p in vals(&[10, 11, 99]) {
1629 for o in vals(&[100, 120, 999]) {
1630 let pat = (s, p, o);
1631 assert_eq!(
1632 idx.match_pattern(pat),
1633 reference(&data, pat),
1634 "budget {budget} pattern {pat:?}"
1635 );
1636 }
1637 }
1638 }
1639 }
1640 }
1641 }
1642
1643 /// The Crossref shape: ONE mega-predicate carrying most of the graph. At
1644 /// the real 64 KiB budget its P-leading a-group must be split across
1645 /// multiple tiles (bounding builder memory), and a bound leading id must
1646 /// route to the whole run of covering tiles with complete results.
1647 #[test]
1648 fn mega_group_splits_across_tiles_and_lookups_stay_complete() {
1649 let hot_p = 7u32;
1650 let mut data: Vec<Triple> = Vec::new();
1651 for i in 0..40_000u32 {
1652 data.push((1_000 + i % 200, hot_p, 50_000 + i));
1653 }
1654 data.push((1, 1, 1));
1655 data.push((2, 2, 2));
1656 let mut b = GraphIndexBuilder::new(); // default INDEX_TILE_BUDGET
1657 for &t in &data {
1658 b.push(t);
1659 }
1660 let idx = b.build();
1661
1662 // PSO (a = predicate): the hot_p group must span several tiles.
1663 let pso = ALL_PERMS
1664 .iter()
1665 .position(|p| matches!(p, IndexPermutation::Pso))
1666 .unwrap();
1667 let covering = idx.tile_sections()[pso]
1668 .iter()
1669 .filter(|t| {
1670 let (lo, hi) = t.leading_range();
1671 lo <= hot_p && hot_p <= hi
1672 })
1673 .count();
1674 assert!(
1675 covering > 1,
1676 "expected the hot predicate split across tiles, got {covering}"
1677 );
1678
1679 // Bound-p lookup must still return every triple of the mega-group —
1680 // probing bound objects in the FIRST, MIDDLE and LAST slices of the
1681 // split run (a mid-run object once returned 0 on the first split file).
1682 for pat in [
1683 (None, Some(hot_p), None),
1684 (Some(1_050), Some(hot_p), None),
1685 (None, Some(hot_p), Some(50_123)), // first slice
1686 (None, Some(hot_p), Some(70_000)), // middle of the run
1687 (None, Some(hot_p), Some(89_999)), // last slice
1688 (None, Some(hot_p), Some(49_000)), // below the range → empty
1689 (None, Some(hot_p), Some(95_000)), // above the range → empty
1690 ] {
1691 assert_eq!(idx.match_pattern(pat), reference(&data, pat), "{pat:?}");
1692 }
1693 }
1694
1695 /// Scans must yield identical results across the directory lifecycle: the
1696 /// first leading-bound scan walks linearly, the second builds the
1697 /// directory, and later ones jump through it — including absent ids
1698 /// (between, below, and above the stored groups).
1699 #[test]
1700 fn directory_backed_scans_match_reference_every_shape() {
1701 let (idx, data) = graph();
1702 let vals = |opts: &[u32]| {
1703 let mut v: Vec<Option<u32>> = opts.iter().map(|&x| Some(x)).collect();
1704 v.push(None);
1705 v
1706 };
1707 for round in 0..3 {
1708 for s in vals(&[1, 2, 3, 0, 9]) {
1709 for p in vals(&[10, 11, 12, 99]) {
1710 for o in vals(&[100, 200, 300, 999]) {
1711 let pat = (s, p, o);
1712 let mut got: Vec<Triple> = idx.scan_iter(pat).collect();
1713 got.sort_unstable();
1714 assert_eq!(got, reference(&data, pat), "round {round} scan {pat:?}");
1715 }
1716 }
1717 }
1718 }
1719 }
1720
1721 /// A tile synopsis must let a routed scan **skip the fetch** of a tile its
1722 /// secondary-column range rules out — and never skip one that could match.
1723 #[test]
1724 fn synopsis_prunes_the_routed_tile_before_fetch() {
1725 use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
1726 use std::sync::Arc;
1727
1728 // One real SPO tile: subject a=5, predicates b∈{10,11}, object c=100.
1729 let block = {
1730 let mut b = TripleBlockBuilder::new();
1731 b.push((5, 10, 100));
1732 b.push((5, 11, 100));
1733 b.build()
1734 };
1735 let fetches = Arc::new(AtomicUsize::new(0));
1736 let (blk, fc) = (block.clone(), fetches.clone());
1737 // The loader returns the (already-decompressed) tile image and counts calls.
1738 let loader: TileLoader = Box::new(move |_si, _ti| {
1739 fc.fetch_add(1, SeqCst);
1740 Some(blk.clone())
1741 });
1742 // Remote SPO directory: one tile, leading a∈[5,5], synopsis b∈[10,11], c∈[100,100].
1743 let dirs = [
1744 vec![(5u32, 5u32, Some((10u32, 11u32, 100u32, 100u32)))],
1745 Vec::new(),
1746 Vec::new(),
1747 Vec::new(),
1748 Vec::new(),
1749 Vec::new(),
1750 ];
1751 let idx = GraphIndex::from_remote_directories(dirs, PermSet::ALL, loader);
1752
1753 // Predicate 99 is outside the tile's b-range → prune, zero fetches, empty.
1754 assert!(idx.match_pattern((Some(5), Some(99), None)).is_empty());
1755 assert_eq!(fetches.load(SeqCst), 0, "synopsis must skip the fetch");
1756
1757 // Object 999 outside the tile's c-range → prune, still zero fetches.
1758 assert!(idx.match_pattern((Some(5), None, Some(999))).is_empty());
1759 assert_eq!(
1760 fetches.load(SeqCst),
1761 0,
1762 "secondary-c prune also skips the fetch"
1763 );
1764
1765 // Predicate 10 is in range → the tile is fetched and the match returned.
1766 assert_eq!(
1767 idx.match_pattern((Some(5), Some(10), None)),
1768 vec![(5, 10, 100)]
1769 );
1770 assert_eq!(
1771 fetches.load(SeqCst),
1772 1,
1773 "an admissible secondary still fetches"
1774 );
1775 }
1776
1777 /// Without a synopsis (`None`), nothing is pruned early — the tile is always
1778 /// fetched and the in-tile zone map does the (correct) pruning, as before.
1779 #[test]
1780 fn absent_synopsis_never_prunes() {
1781 use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
1782 use std::sync::Arc;
1783 let block = {
1784 let mut b = TripleBlockBuilder::new();
1785 b.push((5, 10, 100));
1786 b.build()
1787 };
1788 let fetches = Arc::new(AtomicUsize::new(0));
1789 let (blk, fc) = (block.clone(), fetches.clone());
1790 let loader: TileLoader = Box::new(move |_si, _ti| {
1791 fc.fetch_add(1, SeqCst);
1792 Some(blk.clone())
1793 });
1794 let dirs = [
1795 vec![(5u32, 5u32, None)],
1796 Vec::new(),
1797 Vec::new(),
1798 Vec::new(),
1799 Vec::new(),
1800 Vec::new(),
1801 ];
1802 let idx = GraphIndex::from_remote_directories(dirs, PermSet::ALL, loader);
1803 // Predicate 99 absent, but with no synopsis the tile is fetched (then the
1804 // zone map yields no match) — correctness preserved, just no fetch saved.
1805 assert!(idx.match_pattern((Some(5), Some(99), None)).is_empty());
1806 assert_eq!(fetches.load(SeqCst), 1, "no synopsis ⇒ no early prune");
1807 }
1808
1809 /// The eight triple-pattern shapes, as `(s, p, o)` boundness.
1810 fn all_patterns() -> Vec<Pattern> {
1811 (0..8u8)
1812 .map(|mask| {
1813 (
1814 (mask & 1 != 0).then_some(1u32),
1815 (mask & 2 != 0).then_some(2u32),
1816 (mask & 4 != 0).then_some(3u32),
1817 )
1818 })
1819 .collect()
1820 }
1821
1822 /// The claim the whole option rests on: **{SPO, POS, OSP} ties the longest
1823 /// bound prefix on all eight pattern shapes**, so the other three never win
1824 /// routing — only sort order. Enumerated and printed rather than asserted in
1825 /// prose, because if it were ever false a lean file would answer a pattern
1826 /// from a worse index without anything failing.
1827 #[test]
1828 fn perm_routing_never_leaves_core() {
1829 for pat in all_patterns() {
1830 let best_all = ALL_PERMS
1831 .iter()
1832 .map(|p| p.leading_bound(pat))
1833 .max()
1834 .unwrap();
1835 let best_core = PermSet::CORE
1836 .iter()
1837 .map(|p| p.leading_bound(pat))
1838 .max()
1839 .unwrap();
1840 let scores: Vec<String> = ALL_PERMS
1841 .iter()
1842 .map(|p| format!("{}={}", p.name(), p.leading_bound(pat)))
1843 .collect();
1844 eprintln!(
1845 "s={} p={} o={} | {} | best6={best_all} best3={best_core} chosen={}",
1846 pat.0.is_some() as u8,
1847 pat.1.is_some() as u8,
1848 pat.2.is_some() as u8,
1849 scores.join(" "),
1850 GraphIndex::best_permutation(pat).name(),
1851 );
1852 assert_eq!(
1853 best_core, best_all,
1854 "the three routing permutations must tie the best of six on {pat:?}"
1855 );
1856 let chosen = GraphIndex::best_permutation(pat);
1857 assert!(
1858 PermSet::CORE.contains(chosen),
1859 "best_permutation chose {} for {pat:?}, outside the routing three",
1860 chosen.name()
1861 );
1862 assert_eq!(
1863 chosen,
1864 GraphIndex::best_permutation_in(PermSet::CORE, pat),
1865 "routing must be identical with three permutations and with six"
1866 );
1867 }
1868 }
1869
1870 /// Restricting the set may never make a merge join pick a *worse-routed*
1871 /// stream than the plain lookup would use: with six the co-sorted
1872 /// permutation always ties the best prefix, and with three the guard
1873 /// declines rather than buying sort order with a whole-section scan.
1874 #[test]
1875 fn sorted_permutation_never_sacrifices_routing() {
1876 let mut declined = Vec::new();
1877 for pat in all_patterns() {
1878 for col in 0..3usize {
1879 let routed_all = GraphIndex::best_permutation(pat).leading_bound(pat);
1880 if let Some(p) = GraphIndex::permutation_sorted_on(pat, col) {
1881 assert_eq!(
1882 p.leading_bound(pat),
1883 routed_all,
1884 "six permutations must always co-sort at the best prefix"
1885 );
1886 assert_eq!(p.roles()[p.leading_bound(pat)], col);
1887 }
1888 match GraphIndex::permutation_sorted_on_in(PermSet::CORE, pat, col) {
1889 Some(p) => {
1890 assert!(PermSet::CORE.contains(p));
1891 assert_eq!(
1892 p.leading_bound(pat),
1893 GraphIndex::best_permutation_in(PermSet::CORE, pat).leading_bound(pat)
1894 );
1895 }
1896 None => {
1897 if GraphIndex::permutation_sorted_on(pat, col).is_some() {
1898 declined.push((pat, col));
1899 }
1900 }
1901 }
1902 }
1903 }
1904 // Exactly the three shapes SOP / PSO / OPS exist for: one bound
1905 // component, sorted on a column no remaining order can lead with after
1906 // it — subject-bound sorted on object, predicate-bound sorted on
1907 // subject, object-bound sorted on predicate.
1908 for (pat, col) in &declined {
1909 let bound: Vec<usize> = (0..3)
1910 .filter(|&i| [pat.0, pat.1, pat.2][i].is_some())
1911 .collect();
1912 assert_eq!(
1913 bound.len(),
1914 1,
1915 "only single-bound shapes lose a merge order"
1916 );
1917 assert_ne!(bound[0], *col);
1918 }
1919 eprintln!("three-permutation merge-join declines: {declined:?}");
1920 assert_eq!(declined.len(), 3, "exactly three (bound, sort) shapes lost");
1921 }
1922
1923 #[test]
1924 fn perm_set_masks_and_positions() {
1925 assert_eq!(PermSet::ALL.len(), 6);
1926 assert_eq!(PermSet::CORE.len(), 3);
1927 assert_eq!(PermSet::CORE.bits(), 0b0000_0111);
1928 assert_eq!(PermSet::CORE.names(), vec!["SPO", "POS", "OSP"]);
1929 assert_eq!(
1930 PermSet::ALL.names(),
1931 vec!["SPO", "POS", "OSP", "SOP", "PSO", "OPS"]
1932 );
1933 // Container position is rank among the STORED permutations, which is
1934 // why the routing three had to lead ALL_PERMS.
1935 for (i, p) in PermSet::CORE.iter().enumerate() {
1936 assert_eq!(PermSet::CORE.position(p), Some(i));
1937 assert_eq!(p.section_index(), i);
1938 }
1939 assert_eq!(PermSet::CORE.position(IndexPermutation::Sop), None);
1940 assert_eq!(PermSet::ALL.position(IndexPermutation::Ops), Some(5));
1941 // Only supersets of the routing three are legal.
1942 assert!(PermSet::from_bits(0b0000_0111).is_ok());
1943 assert!(PermSet::from_bits(0b0011_1111).is_ok());
1944 assert!(PermSet::from_bits(0b0000_1111).is_ok());
1945 assert!(PermSet::from_bits(0b0000_0011).is_err());
1946 assert!(PermSet::from_bits(0b0011_1000).is_err());
1947 assert!(PermSet::from_bits(0b1000_0111).is_err());
1948 }
1949
1950 /// A lean index answers every pattern shape with exactly the rows a full
1951 /// one does — the property that makes the option safe at all.
1952 #[test]
1953 fn three_and_six_agree_on_every_pattern() {
1954 let triples: Vec<Triple> = (0..200u32)
1955 .map(|i| (i % 17 + 1, i % 5 + 1, i % 23 + 1))
1956 .collect();
1957 let six = GraphIndexBuilder::from_triples(triples.clone())
1958 .with_tile_budget(128)
1959 .build();
1960 let three = GraphIndexBuilder::from_triples(triples)
1961 .with_tile_budget(128)
1962 .with_perms(PermSet::CORE)
1963 .build();
1964 assert_eq!(six.perms(), PermSet::ALL);
1965 assert_eq!(three.perms(), PermSet::CORE);
1966 for perm in [
1967 IndexPermutation::Sop,
1968 IndexPermutation::Pso,
1969 IndexPermutation::Ops,
1970 ] {
1971 assert!(three.tile_sections()[perm.section_index()].is_empty());
1972 }
1973 for a in [None, Some(3u32)] {
1974 for b in [None, Some(2u32)] {
1975 for c in [None, Some(9u32)] {
1976 let mut x = six.match_pattern((a, b, c));
1977 let mut y = three.match_pattern((a, b, c));
1978 x.sort_unstable();
1979 y.sort_unstable();
1980 assert_eq!(x, y, "pattern {:?} disagreed", (a, b, c));
1981 }
1982 }
1983 }
1984 }
1985}