Skip to main content

rete_core/
bgp.rs

1//! Basic Graph Pattern (BGP) evaluation — the core of SPARQL (SPEC.md §8,
2//! stage 1). A BGP is a set of triple patterns whose variables join on equality;
3//! evaluating it yields variable bindings.
4//!
5//! Evaluation is a left-deep **hash join**: each pattern is scanned from the
6//! index *once* (binding only its own constant terms), producing a relation of
7//! candidate rows; that relation is then joined against the running solution set
8//! on the variables they share. This is O(scan + matches) per pattern rather
9//! than the O(bindings × scan) of a per-binding nested-loop probe — the
10//! difference between sub-second and minutes once a pattern binds tens of
11//! thousands of rows. Correctness does not depend on pattern order.
12//!
13//! Solutions are slot `Row`s of tagged dictionary ids (see `crate::row`):
14//! joins hash and compare integers, and terms are resolved to strings only at
15//! the engine's projection boundary, never per intermediate row.
16
17use std::collections::{BTreeMap, HashMap};
18
19use crate::file::Rete;
20use crate::index::{GraphIndex, Pattern, Tile};
21use crate::row::{Ctx, Row, Slots, Val};
22
23/// A term in a pattern: a named variable or a constant term token.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum PatternTerm {
26    Var(String),
27    Const(String),
28}
29
30impl PatternTerm {
31    /// `?x` → variable `x`; anything else → a constant.
32    pub fn parse(token: &str) -> Self {
33        if let Some(name) = token.strip_prefix('?') {
34            PatternTerm::Var(name.to_string())
35        } else {
36            PatternTerm::Const(token.to_string())
37        }
38    }
39}
40
41/// A triple pattern `(subject, predicate, object)` of [`PatternTerm`]s.
42#[derive(Debug, Clone)]
43pub struct TriplePattern {
44    pub s: PatternTerm,
45    pub p: PatternTerm,
46    pub o: PatternTerm,
47}
48
49/// A solution: variable name → bound term (the public, resolved form).
50pub type Binding = BTreeMap<String, String>;
51
52// --- integer join core ------------------------------------------------------
53//
54// Variables bind to a tagged i64: a node ID `n` is stored as `n` (>= 0), a
55// predicate ID `p` as `-(p+1)` (< 0). Nodes are the unified subject/object space
56// so a variable joins consistently across subject and object positions. A
57// predicate value whose term is also a node is canonicalized to the node id
58// (`Resolver::canon_id`), so cross-role joins match exactly when the term
59// strings match.
60
61/// A pattern position lowered to slot/integer space.
62#[derive(Clone, Copy)]
63enum SlotTerm {
64    Var(usize),
65    Node(u32),
66    Pred(u32),
67}
68
69fn pred_tag(p: u32) -> i64 {
70    -(p as i64) - 1
71}
72
73/// Register every variable in `patterns` with the slot map.
74pub(crate) fn collect_pattern_slots(patterns: &[TriplePattern], slots: &mut Slots) {
75    for p in patterns {
76        for t in [&p.s, &p.p, &p.o] {
77            if let PatternTerm::Var(v) = t {
78                slots.add(v);
79            }
80        }
81    }
82}
83
84/// Lower patterns to slot/integer space; `None` if a constant term is unknown
85/// (making the whole BGP unsatisfiable) or a variable has no slot.
86fn lower(patterns: &[TriplePattern], ctx: &Ctx) -> Option<Vec<(SlotTerm, SlotTerm, SlotTerm)>> {
87    let dict = ctx.rete.dictionary();
88    let node = |t: &PatternTerm| -> Option<SlotTerm> {
89        match t {
90            PatternTerm::Var(v) => ctx.slots.slot(v).map(SlotTerm::Var),
91            PatternTerm::Const(c) => dict.node_of_term(c).map(SlotTerm::Node),
92        }
93    };
94    let pred = |t: &PatternTerm| -> Option<SlotTerm> {
95        match t {
96            PatternTerm::Var(v) => ctx.slots.slot(v).map(SlotTerm::Var),
97            PatternTerm::Const(c) => dict.predicate_id(c).map(SlotTerm::Pred),
98        }
99    };
100    let mut lowered = Vec::with_capacity(patterns.len());
101    for p in patterns {
102        lowered.push((node(&p.s)?, pred(&p.p)?, node(&p.o)?));
103    }
104    Some(lowered)
105}
106
107/// Evaluate a BGP against the file's default graph, returning all solutions
108/// resolved to terms (the public convenience API).
109pub fn eval_bgp(rete: &Rete, patterns: &[TriplePattern]) -> Vec<Binding> {
110    let mut slots = Slots::new();
111    collect_pattern_slots(patterns, &mut slots);
112    let ctx = Ctx::new(rete, slots);
113    eval_bgp_rows(&ctx, rete.default_index(), patterns)
114        .into_iter()
115        .map(|row| row_to_binding(&ctx, &row))
116        .collect()
117}
118
119/// Resolve a row to a named-term binding (every bound slot). Uses the uncached
120/// decode — this is the output boundary, where terms are typically seen once.
121pub(crate) fn row_to_binding(ctx: &Ctx, row: &Row) -> Binding {
122    let mut b = Binding::new();
123    for (i, v) in row.iter().enumerate() {
124        if let Some(val) = v {
125            if let Some(t) = ctx.resolver.str_once(val) {
126                b.insert(ctx.slots.name(i).to_string(), t);
127            }
128        }
129    }
130    b
131}
132
133/// Evaluate a BGP to slot rows against a specific graph `index` (joins run on
134/// integer ids; the shared dictionary comes from `ctx`).
135pub(crate) fn eval_bgp_rows(ctx: &Ctx, index: &GraphIndex, patterns: &[TriplePattern]) -> Vec<Row> {
136    // An empty BGP has exactly one (empty) solution.
137    if patterns.is_empty() {
138        return vec![ctx.slots.empty_row()];
139    }
140    // Lower all patterns; a missing constant term makes the BGP empty.
141    let Some(lowered) = lower(patterns, ctx) else {
142        return Vec::new();
143    };
144
145    // Join the patterns most-constrained-first (and keeping the join connected),
146    // so intermediate relations stay small. Pure reordering: the hash join is
147    // order-independent, so the result is unchanged.
148    let order = selectivity_order(ctx, index, &lowered);
149    // What a full scan of each pattern would produce (its own constants only),
150    // from the tile directory: the probe-vs-scan decision below compares it
151    // with the running result.
152    let scan_est = tile_estimates(ctx, index, &lowered, &std::collections::HashSet::new());
153    let mut rows: Vec<Row> = vec![ctx.slots.empty_row()];
154    let mut bound: Vec<usize> = Vec::new();
155    let mut merged: [bool; 2] = [false, false];
156
157    // Merge-join seed: if the two cheapest patterns share a same-role variable the
158    // 6-permutation index can co-sort them on, join them with a linear two-pointer
159    // **merge** (no hash table) and skip both below. Falls back silently to the
160    // hash/probe path when the shape doesn't qualify.
161    if order.len() >= 2 {
162        if let Some((rel, slots)) =
163            try_merge_join(ctx, index, &lowered[order[0]], &lowered[order[1]])
164        {
165            rows = rel;
166            bound = slots;
167            merged = [true, true];
168        }
169    }
170
171    for (k, &idx) in order.iter().enumerate() {
172        if k < 2 && merged[k] {
173            continue; // already consumed by the merge seed
174        }
175        let t = lowered[idx];
176        // Hybrid join: once the running result is small and the next pattern shares
177        // an already-bound variable, **probe** it per row through the index instead
178        // of scanning its whole extent and hash-joining. A left-deep hash join
179        // otherwise materializes every pattern's full scan even when only a handful
180        // of rows survive — e.g. a `?x rdf:type C` over thousands of instances when
181        // the prefix already pinned ?x to 26 rows. Probing turns that O(scan) into
182        // O(rows × lookup). The full scan + hash join stays the path when the prefix
183        // is large (one scan beats many probes) or the pattern is a cartesian join
184        // (no shared bound variable to constrain the probe).
185        let shares_bound = pattern_slots(&t).iter().any(|s| bound.contains(s));
186        // Decide probe (per-row index lookup) vs scan + hash join. In memory a
187        // probe is a cheap lookup, so we probe up to a moderate prefix. Remotely
188        // each probe is a byte-range round-trip: probing a 500-row prefix is 500
189        // sequential fetches, while scanning a selectively-bound pattern is a few
190        // coalesced reads — so remotely we only probe a broad (whole-predicate)
191        // pattern, or a tiny prefix, and never a large one.
192        //
193        // Whatever the reader, probe when the scan would be far larger than
194        // the rows asking about it: 517 `?s a :Service` rows must not scan
195        // and materialize all 109 M `publicationYear` triples to keep 517.
196        let do_probe = !bound.is_empty()
197            && shares_bound
198            && !rows.is_empty()
199            && (probe_beats_scan(rows.len(), scan_est[idx])
200                || if index.is_remote() {
201                    rows.len() <= remote_probe_max(index)
202                        && (rows.len() <= REMOTE_PROBE_MIN || !pattern_is_selective(&t))
203                } else {
204                    rows.len() <= BGP_PROBE_THRESHOLD
205                });
206        if do_probe {
207            let taken = std::mem::take(&mut rows);
208            // Over a lazy reader each probe is a byte-range round trip, and they run
209            // one at a time. First fault the tiles the *whole* batch will route to,
210            // together — one coalesced parallel read per section instead of N
211            // sequential ones — so the probes below hit a warm tile cache. No-op
212            // for a local index (every tile is already resident).
213            if index.is_remote() {
214                let pats: Vec<Pattern> = taken
215                    .iter()
216                    .filter_map(|base| {
217                        match (
218                            probe_subject(ctx, &t.0, base),
219                            probe_predicate(ctx, &t.1, base),
220                            probe_object(ctx, &t.2, base),
221                        ) {
222                            (Some(s), Some(p), Some(o)) => Some((s, p, o)),
223                            _ => None,
224                        }
225                    })
226                    .collect();
227                index.prefetch_probe_tiles(&pats);
228            }
229            let mut next: Vec<Row> = Vec::with_capacity(taken.len());
230            for base in taken {
231                next.extend(probe_rows(ctx, index, t, base));
232            }
233            rows = next;
234        } else {
235            let Some((rel, rel_slots)) = pattern_rows(ctx, index, &t) else {
236                return Vec::new();
237            };
238            rows = hash_join(rows, &bound, rel, &rel_slots);
239        }
240        for s in pattern_slots(&t) {
241            if !bound.contains(&s) {
242                bound.push(s);
243            }
244        }
245        if rows.is_empty() {
246            break;
247        }
248    }
249    rows
250}
251
252/// Merge-join two base patterns that share **exactly one** variable sitting in the
253/// **same canonical position** (both subject, or both object) in each. That is the
254/// only shape rete's role-aware id spaces let a sort-merge align: the index sorts
255/// subject-ids and object-ids in independent spaces, so a cross-role (subject↔
256/// object) join's two streams are not co-sorted on the join value. Each side is
257/// streamed from the 6-permutation index already sorted on that column
258/// ([`GraphIndex::scan_iter_sorted_on`]), so the join is a linear two-pointer
259/// merge with no hash table — equal-key runs cross-product. Returns
260/// `(rows, bound_slots)`, or `None` when the shape/index can't support a merge and
261/// the caller should hash-join instead. The result multiset equals the hash
262/// join's (a join is order-independent).
263fn try_merge_join(
264    ctx: &Ctx,
265    index: &GraphIndex,
266    ta: &(SlotTerm, SlotTerm, SlotTerm),
267    tb: &(SlotTerm, SlotTerm, SlotTerm),
268) -> Option<(Vec<Row>, Vec<usize>)> {
269    let dict = ctx.rete.dictionary();
270    let sa = pattern_slots(ta);
271    let sb = pattern_slots(tb);
272    let shared: Vec<usize> = sa.iter().copied().filter(|s| sb.contains(s)).collect();
273    if shared.len() != 1 {
274        return None;
275    }
276    let v = shared[0];
277    let col_of = |t: &(SlotTerm, SlotTerm, SlotTerm)| -> Option<usize> {
278        [&t.0, &t.1, &t.2]
279            .iter()
280            .position(|x| matches!(x, SlotTerm::Var(i) if *i == v))
281    };
282    let ca = col_of(ta)?;
283    let cb = col_of(tb)?;
284    if ca != cb {
285        return None; // cross-role: the two role id-spaces aren't co-sorted
286    }
287    // A merge MATERIALIZES both sides in full (`collect` below builds a Vec per
288    // side). When one side is a pinpoint (a bound leading key routing to a few
289    // KB of tiles) and the other is fat (a broad predicate spanning megabytes),
290    // the linear merge costs building — and, on a remote index, FETCHING — the
291    // fat extent end-to-end: gigabytes over HTTP, and the 32-bit wasm OOM
292    // behind `?x wdt:P279 <C> . ?x rdfs:label ?l` on a 185M-triple remote
293    // graph. The hybrid loop handles that shape strictly better (scan the
294    // pinpoint side, probe the fat one per surviving row), so leave the merge
295    // seed to comparably-sized sides.
296    if merge_seed_lopsided(ctx, index, ta, tb) {
297        return None;
298    }
299    let lower = |t: &(SlotTerm, SlotTerm, SlotTerm)| -> Option<Pattern> {
300        Some((
301            const_subject(&t.0, dict)?,
302            const_predicate(&t.1)?,
303            const_object(&t.2, dict)?,
304        ))
305    };
306    // Materialize each side as `(join-key, row)`, already ascending in the join
307    // column (the index streams it sorted there).
308    let collect = |t: &(SlotTerm, SlotTerm, SlotTerm), col: usize| -> Option<Vec<(u32, Row)>> {
309        let pat = lower(t)?;
310        let mut out = Vec::new();
311        for tri in index.scan_iter_sorted_on(pat, col)? {
312            if let Some(r) = triple_row(ctx, t, tri) {
313                out.push(([tri.0, tri.1, tri.2][col], r));
314            }
315        }
316        Some(out)
317    };
318    let rows_a = collect(ta, ca)?;
319    let rows_b = collect(tb, cb)?;
320
321    let mut out: Vec<Row> = Vec::new();
322    let (mut i, mut j) = (0usize, 0usize);
323    while i < rows_a.len() && j < rows_b.len() {
324        match rows_a[i].0.cmp(&rows_b[j].0) {
325            std::cmp::Ordering::Less => i += 1,
326            std::cmp::Ordering::Greater => j += 1,
327            std::cmp::Ordering::Equal => {
328                let key = rows_a[i].0;
329                let (i0, j0) = (i, j);
330                while i < rows_a.len() && rows_a[i].0 == key {
331                    i += 1;
332                }
333                while j < rows_b.len() && rows_b[j].0 == key {
334                    j += 1;
335                }
336                for (_, ra) in &rows_a[i0..i] {
337                    for (_, rb) in &rows_b[j0..j] {
338                        // Combine the two rows; they agree on the shared slot `v`
339                        // (same key ⇒ same node value at the same column).
340                        let mut row = ra.clone();
341                        let mut ok = true;
342                        for (slot, val) in rb.iter().enumerate() {
343                            if let Some(val) = val {
344                                match &row[slot] {
345                                    Some(existing) if existing != val => {
346                                        ok = false;
347                                        break;
348                                    }
349                                    _ => row[slot] = Some(val.clone()),
350                                }
351                            }
352                        }
353                        if ok {
354                            out.push(row);
355                        }
356                    }
357                }
358            }
359        }
360    }
361    let mut slots = pattern_slots(ta);
362    for s in pattern_slots(tb) {
363        if !slots.contains(&s) {
364            slots.push(s);
365        }
366    }
367    Some((out, slots))
368}
369
370/// Probe the join tail (instead of full-scanning) once the running BGP result is
371/// at most this many rows: each remaining pattern that shares a bound variable is
372/// then resolved by `rows × index-lookup` rather than a whole-extent scan. Kept
373/// small so the probe count is always cheap in absolute terms — the dominant win
374/// is the large-scan-after-tiny-prefix case (a `?x a C` over thousands when the
375/// prefix already pinned ?x to a few dozen rows); a *moderately* large prefix
376/// keeps the one-pass scan + hash join, which beats thousands of probes.
377const BGP_PROBE_THRESHOLD: usize = 512;
378
379/// A pattern whose estimated scan is below this many triples is never worth
380/// planning around: scanning it is cheap whatever the other side holds. Keeps
381/// every small graph (the W3C suite, the playground datasets) on the joins it
382/// always used.
383const PROBE_SCAN_FLOOR: f64 = 65_536.0;
384
385/// Probe instead of scanning once the scan is at least this many times larger
386/// than the rows that would probe it. A probe is a tile-directory binary search
387/// plus a group lookup in an (often cached) tile; a scan pays per triple, and
388/// on the hash-join path also materializes every one of them.
389const PROBE_RATIO: f64 = 16.0;
390
391/// Is probing `rows` bound rows cheaper than scanning a pattern estimated at
392/// `scan_est` triples?
393fn probe_beats_scan(rows: usize, scan_est: f64) -> bool {
394    scan_est >= limits().probe_floor && (rows as f64) * PROBE_RATIO <= scan_est
395}
396
397/// The most rows that may probe a BGP whose cheapest pattern is estimated at
398/// `est` triples while probing still beats evaluating it whole
399/// ([`probe_beats_scan`]); `None` below the probe floor, where the BGP is
400/// cheap to evaluate either way.
401pub(crate) fn probe_row_budget(est: f64) -> Option<usize> {
402    (est >= limits().probe_floor).then(|| (est / PROBE_RATIO) as usize)
403}
404
405/// The smallest tile-directory estimate ([`GraphIndex::estimate_matches`])
406/// among a BGP's patterns — a floor on what evaluating it whole would scan.
407/// `None` when a constant is not in the file (the BGP matches nothing).
408pub(crate) fn bgp_min_estimate(
409    ctx: &Ctx,
410    index: &GraphIndex,
411    patterns: &[TriplePattern],
412) -> Option<f64> {
413    let lowered = lower(patterns, ctx)?;
414    tile_estimates(ctx, index, &lowered, &std::collections::HashSet::new())
415        .into_iter()
416        .reduce(f64::min)
417}
418
419/// Run `f` with the probe floor set to `floor` (tests only), so a join's
420/// probe paths can be driven over a graph small enough to check by hand.
421#[cfg(test)]
422pub(crate) fn with_probe_floor<T>(floor: f64, f: impl FnOnce() -> T) -> T {
423    let saved = TEST_LIMITS.with(|c| c.get());
424    TEST_LIMITS.with(|c| {
425        c.set(Some(PlanLimits {
426            probe_floor: floor,
427            ..limits()
428        }))
429    });
430    let out = f();
431    TEST_LIMITS.with(|c| c.set(saved));
432    out
433}
434
435/// The planner's size thresholds, gathered so tests can shrink them and drive
436/// every join path over a graph small enough to check by hand.
437#[derive(Clone, Copy)]
438struct PlanLimits {
439    probe_floor: f64,
440    stream_seed: f64,
441    hash_build: f64,
442}
443
444#[cfg(test)]
445thread_local! {
446    static TEST_LIMITS: std::cell::Cell<Option<PlanLimits>> = const { std::cell::Cell::new(None) };
447}
448
449fn limits() -> PlanLimits {
450    #[cfg(test)]
451    if let Some(l) = TEST_LIMITS.with(|c| c.get()) {
452        return l;
453    }
454    PlanLimits {
455        probe_floor: PROBE_SCAN_FLOOR,
456        stream_seed: STREAM_SEED_ROWS,
457        hash_build: HASH_BUILD_ROWS,
458    }
459}
460
461/// A BGP whose most selective pattern is estimated above this many matches is
462/// **streamed** rather than joined into a materialized prefix
463/// ([`BgpSolutions`]): every later pattern is then joined per row (probe) or
464/// against a small materialized pattern (hash), so resident memory no longer
465/// follows the largest pattern. `?s dcite:doi ?d . ?s dcite:fromSnapshot ?x`
466/// materialized 109 M rows before joining and ran a 16 GB container out of
467/// memory.
468const STREAM_SEED_ROWS: f64 = (1u64 << 21) as f64;
469
470/// On the streaming path, a later pattern estimated at no more than this many
471/// matches is scanned once into a hash table; a larger one is probed per row.
472const HASH_BUILD_ROWS: f64 = (1u64 << 20) as f64;
473
474/// Tile-directory cardinality estimates ([`GraphIndex::estimate_matches`]) for
475/// each lowered pattern. A variable an outer seed row binds is a constant at
476/// run time but unknown now: it scales the estimate by the same default
477/// selectivities [`pattern_estimates`] falls back to. An unsatisfiable
478/// constant estimates 0.
479fn tile_estimates(
480    ctx: &Ctx,
481    index: &GraphIndex,
482    lowered: &[(SlotTerm, SlotTerm, SlotTerm)],
483    seed: &std::collections::HashSet<usize>,
484) -> Vec<f64> {
485    const SEL_SUBJECT: f64 = 0.001;
486    const SEL_OBJECT: f64 = 0.02;
487    const SEL_PREDICATE: f64 = 0.05;
488    let dict = ctx.rete.dictionary();
489    let total = ctx.rete.header().quad_count;
490    let seeded = |x: &SlotTerm| matches!(x, SlotTerm::Var(v) if seed.contains(v));
491    // A seeded subject/object of a constant predicate takes that predicate's
492    // MEASURED selectivity from `query_stats` (the SCHEMA section, kind 9, or
493    // a resident pyramid-meta) instead of the fixed default. The tile
494    // directory cannot see a value that only exists at run time, so this is
495    // the one estimate here the statistics improve. They are read only when
496    // some pattern needs them: one small range, once per session, on a lazy
497    // open — never for a query whose patterns have no seeded position.
498    let needs_stats = lowered
499        .iter()
500        .any(|t| matches!(t.1, SlotTerm::Pred(_)) && (seeded(&t.0) || seeded(&t.2)));
501    let stats: std::collections::HashMap<u32, &crate::meta::PredStat> = if needs_stats {
502        ctx.rete
503            .planner_stats(true)
504            .iter()
505            .map(|s| (s.predicate, s))
506            .collect()
507    } else {
508        std::collections::HashMap::new()
509    };
510    lowered
511        .iter()
512        .map(|t| {
513            let (Some(s), Some(p), Some(o)) = (
514                const_subject(&t.0, dict),
515                const_predicate(&t.1),
516                const_object(&t.2, dict),
517            ) else {
518                return 0.0;
519            };
520            let mut est = index.estimate_matches((s, p, o), total);
521            if est == 0.0 {
522                return 0.0;
523            }
524            let st = match t.1 {
525                SlotTerm::Pred(pid) => stats.get(&pid).copied(),
526                _ => None,
527            };
528            if seeded(&t.0) {
529                est *= match st {
530                    Some(st) if st.distinct_subjects > 0 => 1.0 / st.distinct_subjects as f64,
531                    _ => SEL_SUBJECT,
532                };
533            }
534            if seeded(&t.1) {
535                est *= SEL_PREDICATE;
536            }
537            if seeded(&t.2) {
538                est *= match st {
539                    Some(st) if st.distinct_objects > 0 => 1.0 / st.distinct_objects as f64,
540                    _ => SEL_OBJECT,
541                };
542            }
543            est.max(1.0)
544        })
545        .collect()
546}
547
548/// Remote (lazy) probe bounds. Over HTTP byte-range reads, a per-row probe is a
549/// network round-trip, so we probe far more reluctantly than in memory: only when
550/// scanning the next pattern would be a *broad* read (a whole-predicate scan,
551/// [`pattern_is_selective`] is false), or the prefix is tiny enough that a handful
552/// of probes is trivial either way. A pattern that carries its own bound
553/// subject/object routes to a narrow index range — cheaper to scan once and
554/// hash-join than to probe per prefix row. Probing a large prefix is always a
555/// loss remotely, so it is capped.
556const REMOTE_PROBE_MIN: usize = 8;
557const REMOTE_PROBE_MAX: usize = 1024;
558
559/// The remote probe budget, widened by the reader's concurrency: a serial
560/// sync-XHR reader (a phone without the COI pool) pays one full round trip per
561/// probe, so its budget stays at [`REMOTE_PROBE_MAX`]; a reader overlapping
562/// 16 range reads (the CLI's thread pool, the asyncified fetch variant, the
563/// COI fetch-worker pool) amortizes ~16 probes per round trip and gets a
564/// proportionally larger budget before the one-pass scan wins again.
565fn remote_probe_max(index: &GraphIndex) -> usize {
566    REMOTE_PROBE_MAX * index.read_concurrency().clamp(1, 16)
567}
568
569/// Batch-fault the tiles that a [`ProbePlan`]'s FIRST pattern will route to
570/// across a batch of seed rows — one coalesced read per section instead of a
571/// blocking round trip per row. Only the first pattern is prefetchable (later
572/// ones bind from earlier probe results), and only rows whose seed binds the
573/// pattern's subject or object are included: a predicate-only pattern routes
574/// to the predicate's WHOLE span, which for the fat sides this path serves
575/// would prefetch the very extent the probe strategy exists to avoid.
576pub(crate) fn prefetch_plan_probes(ctx: &Ctx, index: &GraphIndex, plan: &ProbePlan, rows: &[Row]) {
577    if !index.is_remote() {
578        return;
579    }
580    let Some(t) = plan.pats.first() else {
581        return;
582    };
583    let mut pats: Vec<Pattern> = Vec::new();
584    for base in rows {
585        if let (Some(s), Some(p), Some(o)) = (
586            probe_subject(ctx, &t.0, base),
587            probe_predicate(ctx, &t.1, base),
588            probe_object(ctx, &t.2, base),
589        ) {
590            if s.is_some() || o.is_some() {
591                pats.push((s, p, o));
592            }
593        }
594    }
595    index.prefetch_probe_tiles(&pats);
596}
597
598/// Batch-fault the tiles that probing `patterns` from `subject_ids` (each
599/// bound onto the subject variable `sv`) will route to — one coalesced read
600/// per section instead of one round trip per candidate. Purely a cache
601/// warmer: correctness is untouched, and a local index is a no-op. Used by
602/// the FILTER-CONTAINS pushdown before it probes its candidate subjects.
603pub(crate) fn prefetch_subject_probes(
604    ctx: &Ctx,
605    index: &GraphIndex,
606    patterns: &[TriplePattern],
607    sv: &str,
608    subject_ids: &[u32],
609) {
610    if !index.is_remote() {
611        return;
612    }
613    let Some(lowered) = lower(patterns, ctx) else {
614        return;
615    };
616    let Some(slot) = ctx.slots.slot(sv) else {
617        return;
618    };
619    let dict = ctx.rete.dictionary();
620    let mut pats: Vec<Pattern> = Vec::new();
621    for &sid in subject_ids {
622        let mut base = ctx.slots.empty_row();
623        base[slot] = Some(Val::Id(dict.subject_node(sid) as i64));
624        for t in &lowered {
625            if !matches!(t.0, SlotTerm::Var(i) if i == slot) {
626                continue;
627            }
628            if let (Some(s), Some(p), Some(o)) = (
629                probe_subject(ctx, &t.0, &base),
630                probe_predicate(ctx, &t.1, &base),
631                probe_object(ctx, &t.2, &base),
632            ) {
633                pats.push((s, p, o));
634            }
635        }
636    }
637    index.prefetch_probe_tiles(&pats);
638}
639
640/// A pattern whose own constant terms (a bound subject or object) already route
641/// it to a narrow index range — a few coalesced range reads to scan in full.
642fn pattern_is_selective(t: &(SlotTerm, SlotTerm, SlotTerm)) -> bool {
643    matches!(t.0, SlotTerm::Node(_)) || matches!(t.2, SlotTerm::Node(_))
644}
645
646/// The (deduped) slots a lowered pattern binds.
647fn pattern_slots(t: &(SlotTerm, SlotTerm, SlotTerm)) -> Vec<usize> {
648    let mut slots: Vec<usize> = Vec::new();
649    for term in [&t.0, &t.1, &t.2] {
650        if let SlotTerm::Var(i) = term {
651            if !slots.contains(i) {
652                slots.push(*i);
653            }
654        }
655    }
656    slots
657}
658
659/// The row value of a matched triple's predicate `p_id`, for a pattern whose
660/// predicate slot is `slot`. Only a variable predicate binds it, so only then
661/// is it canonicalized (a predicate that is also a node becomes the node id):
662/// that is a dictionary lookup of the predicate as a node, memoized per query,
663/// but up to three dependent chunk reads on a lazy open for each new predicate
664/// — reads a constant predicate never needed. Otherwise the value is unused.
665fn pred_val(ctx: &Ctx, slot: &SlotTerm, p_id: u32) -> i64 {
666    match slot {
667        SlotTerm::Var(_) => ctx.resolver.canon_id(pred_tag(p_id)),
668        _ => pred_tag(p_id),
669    }
670}
671
672/// Build a solution row for one scanned triple, enforcing repeated variables
673/// *within* the pattern (e.g. `?x p ?x`). `None` = the triple doesn't satisfy
674/// a repeated variable.
675fn triple_row(
676    ctx: &Ctx,
677    t: &(SlotTerm, SlotTerm, SlotTerm),
678    (s_id, p_id, o_id): (u32, u32, u32),
679) -> Option<Row> {
680    let dict = ctx.rete.dictionary();
681    let s_val = dict.subject_node(s_id) as i64;
682    let p_val = pred_val(ctx, &t.1, p_id);
683    let o_val = dict.object_node(o_id) as i64;
684    let mut row = ctx.slots.empty_row();
685    for (term, val) in [(&t.0, s_val), (&t.1, p_val), (&t.2, o_val)] {
686        if let SlotTerm::Var(i) = term {
687            match row[*i] {
688                Some(Val::Id(existing)) if existing != val => return None,
689                Some(_) => {}
690                None => row[*i] = Some(Val::Id(val)),
691            }
692        }
693    }
694    Some(row)
695}
696
697/// Lazily scan one lowered pattern as a stream of solution rows. The scan
698/// constrains only the pattern's constant terms (bound variables are joined in
699/// afterwards, not pushed into the scan). `None` = a constant is unsatisfiable
700/// (unknown to the dictionary or in an impossible role), which empties the BGP.
701fn scan_rows<'q>(
702    ctx: &'q Ctx<'q>,
703    index: &'q GraphIndex,
704    t: (SlotTerm, SlotTerm, SlotTerm),
705) -> Option<impl Iterator<Item = Row> + 'q> {
706    let dict = ctx.rete.dictionary();
707    let (sid, pid, oid) = (
708        const_subject(&t.0, dict)?,
709        const_predicate(&t.1)?,
710        const_object(&t.2, dict)?,
711    );
712    Some(
713        index
714            .scan_iter((sid, pid, oid))
715            .filter_map(move |triple| triple_row(ctx, &t, triple)),
716    )
717}
718
719/// Scan one lowered pattern into a materialized relation, returning the rows
720/// and the slots the pattern binds. `None` = a constant is unsatisfiable.
721fn pattern_rows(
722    ctx: &Ctx,
723    index: &GraphIndex,
724    t: &(SlotTerm, SlotTerm, SlotTerm),
725) -> Option<(Vec<Row>, Vec<usize>)> {
726    let slots = pattern_slots(t);
727    let mut rel: Vec<Row> = Vec::new();
728    let dict = ctx.rete.dictionary();
729    let (sid, pid, oid) = (
730        const_subject(&t.0, dict)?,
731        const_predicate(&t.1)?,
732        const_object(&t.2, dict)?,
733    );
734    // Stream the matches with the lazy cursor — the hash join is
735    // order-independent, so no canonical re-sort is needed here.
736    for triple in index.scan_iter((sid, pid, oid)) {
737        if let Some(row) = triple_row(ctx, t, triple) {
738            rel.push(row);
739        }
740    }
741    Some((rel, slots))
742}
743
744/// A per-pattern cardinality estimate, smaller = cheaper to scan. The base is
745/// the **exact** per-predicate triple count (sum of the summary quotient graph's
746/// super-edge counts) when the predicate is bound, else the whole-graph total.
747/// A bound subject/object (a constant, or a variable already bound by `seed`)
748/// then scales it down by that predicate's **measured selectivity** from the
749/// `query_stats` block — `1 / distinct_subjects` for a bound subject (so
750/// `<s> <p> ?o` ≈ the average objects per subject; exactly 1 for a functional
751/// predicate), `1 / distinct_objects` for a bound object. Files built before the
752/// `query_stats` block fall back to fixed default selectivities. `None` when the
753/// file carries no pyramid summary at all — ordering then uses the constant-count
754/// heuristic.
755fn pattern_estimates(
756    ctx: &Ctx,
757    lowered: &[(SlotTerm, SlotTerm, SlotTerm)],
758    seed: &std::collections::HashSet<usize>,
759) -> Option<Vec<f64>> {
760    // Only use the summary when it's already in memory — never fault it just to
761    // plan (the lazy remote path defers the pyramid by design).
762    let pyr = ctx.rete.pyramid_if_loaded()?;
763    let mut pred: std::collections::HashMap<u32, u64> = std::collections::HashMap::new();
764    for e in &pyr.summary {
765        *pred.entry(e.predicate).or_insert(0) += e.count as u64;
766    }
767    // Measured per-predicate distinct subjects/objects: the SCHEMA section
768    // (kind 9) — where a file built since it existed keeps them, its
769    // pyramid-meta holding only the community summary — else the pyramid-meta's
770    // query-stats block; empty on files built before either → the default
771    // selectivities. The pyramid is resident here, so the (small) section is
772    // too on a resident open, and costs one range once on a lazy one.
773    let stats: std::collections::HashMap<u32, &crate::meta::PredStat> = ctx
774        .rete
775        .planner_stats(true)
776        .iter()
777        .map(|s| (s.predicate, s))
778        .collect();
779    let total = ctx.rete.header().quad_count.max(1) as f64;
780    let num_preds = pred.len().max(1) as f64;
781    // Defaults used only when query_stats has no entry for the predicate.
782    const SEL_SUBJECT: f64 = 0.001;
783    const SEL_OBJECT: f64 = 0.02;
784    let node_bound = |t: &SlotTerm| match t {
785        SlotTerm::Node(_) => true,
786        SlotTerm::Var(v) => seed.contains(v),
787        SlotTerm::Pred(_) => false,
788    };
789    Some(
790        lowered
791            .iter()
792            .map(|t| {
793                // Base from the predicate: exact total for a constant predicate,
794                // the average predicate total for a seed-bound predicate variable
795                // (the value is known to the probe but not at plan time), else the
796                // whole graph. `st` is the measured stats for a constant predicate.
797                let (base, st) = match t.1 {
798                    SlotTerm::Pred(p) => {
799                        (*pred.get(&p).unwrap_or(&0) as f64, stats.get(&p).copied())
800                    }
801                    SlotTerm::Var(v) if seed.contains(&v) => (total / num_preds, None),
802                    _ => (total, None),
803                };
804                let mut est = base.max(1.0);
805                if node_bound(&t.0) {
806                    est *= match st {
807                        Some(s) if s.distinct_subjects > 0 => 1.0 / s.distinct_subjects as f64,
808                        _ => SEL_SUBJECT,
809                    };
810                }
811                if node_bound(&t.2) {
812                    est *= match st {
813                        Some(s) if s.distinct_objects > 0 => 1.0 / s.distinct_objects as f64,
814                        _ => SEL_OBJECT,
815                    };
816                }
817                est.max(1.0)
818            })
819            .collect(),
820    )
821}
822
823/// The minimum, over a BGP's patterns, of [`pattern_scan_bytes`] — the
824/// cheapest single-pattern scan the hash-join path would at least perform to
825/// materialize this BGP. `None` = a constant term is unknown to the
826/// dictionary (the BGP is unsatisfiable; the caller short-circuits anyway).
827/// Unlike [`pattern_estimates`], this needs no pyramid summary, so it works on
828/// remote-lazy files — exactly where the answer matters most.
829pub(crate) fn bgp_min_scan_bytes(
830    ctx: &Ctx,
831    index: &GraphIndex,
832    patterns: &[TriplePattern],
833) -> Option<u64> {
834    let lowered = lower(patterns, ctx)?;
835    lowered
836        .iter()
837        .map(|t| pattern_scan_bytes(ctx, index, t))
838        .min()
839}
840
841/// One pattern's cheapest scan extent in BYTES — what `rete cost` previews
842/// (the planner's gates use row estimates instead): for each permutation, sum the
843/// encoded lengths of the tiles a scan of this pattern would route to (all
844/// tiles covering the bound leading key, or the whole section when the leading
845/// key is unbound), and take the minimum. Tile *counts* are useless here — a
846/// broad predicate can sit inside one giant tile — but the directory's byte
847/// lengths expose it. Reads only resident metadata, never tile data.
848///
849/// A bound term routes by its id **in the role its position has**: a tile's
850/// leading range is in subject-, predicate- or object-id space, not in the
851/// unified node space a lowered constant carries. Routing the node id instead
852/// was off by one for subjects and off by the whole subject-only block for an
853/// object-only term (every literal), so `?id :value "10.1234/x"` could cost
854/// "0 bytes" and pass the merge-join gate against a fat partner, which the
855/// merge then materialized in full. A constant that has no id in the role
856/// (never a subject, say) matches nothing: 0 bytes is then exact.
857fn pattern_scan_bytes(ctx: &Ctx, index: &GraphIndex, t: &(SlotTerm, SlotTerm, SlotTerm)) -> u64 {
858    let dict = ctx.rete.dictionary();
859    match (
860        const_subject(&t.0, dict),
861        const_predicate(&t.1),
862        const_object(&t.2, dict),
863    ) {
864        (Some(s), Some(p), Some(o)) => routed_scan_bytes(index, (s, p, o)),
865        _ => 0,
866    }
867}
868
869/// [`pattern_scan_bytes`] for a pattern already in role-id space.
870pub(crate) fn routed_scan_bytes(index: &GraphIndex, pattern: Pattern) -> u64 {
871    let sections = index.tile_sections();
872    let mut best = u64::MAX;
873    // Only the permutations the FILE carries: an absent section is an empty
874    // `Vec<Tile>`, and summing its (zero) tile lengths would report a free scan
875    // for the one permutation that cannot be scanned at all.
876    for perm in index.perms().iter() {
877        let tiles = sections[perm.section_index()];
878        best = best.min(match perm.order_pattern(pattern)[0] {
879            Some(id) => tiles
880                .iter()
881                .filter(|tile| {
882                    let (lo, hi) = tile.leading_range();
883                    lo <= id && id <= hi
884                })
885                .map(Tile::encoded_len)
886                .sum(),
887            None => tiles.iter().map(Tile::encoded_len).sum(),
888        });
889    }
890    best
891}
892
893/// Estimated matches above which a join side counts as "fat": materializing
894/// (or, on a remote index, fetching) a million rows to build a hash/merge side
895/// is past the point where probing it per surviving row wins. Shared by the
896/// merge-seed asymmetry gate here and the force-probe gates in `sparql::eval`.
897///
898/// A ROW estimate ([`GraphIndex::estimate_matches`]: routed tiles × rows per
899/// tile), not a byte figure. Tiles are cut by their encoded size before
900/// compression, so the estimate does not move when only the compression does;
901/// the compressed tile bytes this gate used to read did — at zstd level 19,
902/// two OpenCitations queries crossed the old 2 MiB line and ran 3–7× slower
903/// with up to 6× the memory — and they differed between a resident open
904/// (decoded sizes) and a lazy one (compressed sizes) of the same file.
905pub(crate) const FAT_SCAN_ROWS: f64 = (1u64 << 20) as f64;
906
907/// Would a merge join of `ta` and `tb` materialize a fat side next to a
908/// pinpoint one? (One side above [`FAT_SCAN_ROWS`] and at least four times the
909/// other.) Then the hybrid loop — scan the small side, probe the fat one — is
910/// strictly better, and the merge seed is declined.
911fn merge_seed_lopsided(
912    ctx: &Ctx,
913    index: &GraphIndex,
914    ta: &(SlotTerm, SlotTerm, SlotTerm),
915    tb: &(SlotTerm, SlotTerm, SlotTerm),
916) -> bool {
917    let est = tile_estimates(ctx, index, &[*ta, *tb], &std::collections::HashSet::new());
918    let (lo, hi) = (est[0].min(est[1]), est[0].max(est[1]));
919    hi >= FAT_SCAN_ROWS && hi / 4.0 >= lo
920}
921
922/// Order pattern indices for a left-deep join: cheapest (smallest estimated
923/// cardinality) first, always preferring a pattern that shares a variable with
924/// the already-joined set so the join stays connected and intermediate relations
925/// stay small. Cardinality comes from [`pattern_estimates`]; with no summary it
926/// falls back to a most-constants-first heuristic. Pure reordering — `hash_join`
927/// is order-independent, so the result multiset is unchanged.
928fn selectivity_order(
929    ctx: &Ctx,
930    index: &GraphIndex,
931    lowered: &[(SlotTerm, SlotTerm, SlotTerm)],
932) -> Vec<usize> {
933    selectivity_order_seeded(ctx, index, lowered, &std::collections::HashSet::new())
934}
935
936/// [`selectivity_order`] with a set of slots already bound by an outer seed
937/// row: those variables count as already connected (and, in the no-summary
938/// fallback, as constants for selectivity).
939fn selectivity_order_seeded(
940    ctx: &Ctx,
941    index: &GraphIndex,
942    lowered: &[(SlotTerm, SlotTerm, SlotTerm)],
943    seed: &std::collections::HashSet<usize>,
944) -> Vec<usize> {
945    // Exact per-predicate statistics when the pyramid is resident; otherwise
946    // (the lazy path, and files built without a pyramid) the tile-directory
947    // estimate, which needs no fetch. That one also counts a class's own
948    // instances (`?s a :C` routes to the tiles of `:C`), so the
949    // class-enumeration rule below, a stand-in for exactly that missing
950    // number, applies only to the pyramid figures, whose `rdf:type`
951    // selectivity is an average over classes.
952    let (estimates, per_class) = match pattern_estimates(ctx, lowered, seed) {
953        Some(e) => (Some(e), false),
954        None => (Some(tile_estimates(ctx, index, lowered, seed)), true),
955    };
956    let consts = |t: &(SlotTerm, SlotTerm, SlotTerm)| {
957        [&t.0, &t.1, &t.2]
958            .into_iter()
959            .filter(|x| match x {
960                SlotTerm::Var(v) => seed.contains(v),
961                _ => true,
962            })
963            .count()
964    };
965    let vars = |t: &(SlotTerm, SlotTerm, SlotTerm)| -> Vec<usize> {
966        [&t.0, &t.1, &t.2]
967            .into_iter()
968            .filter_map(|x| match x {
969                SlotTerm::Var(v) => Some(*v),
970                _ => None,
971            })
972            .collect()
973    };
974    // Higher score = picked sooner. With summary stats that's the negated
975    // estimate (smaller cardinality wins); without, the constant count (old
976    // behaviour, byte-identical ordering for pyramid-less files).
977    let score = |i: usize| -> f64 {
978        match &estimates {
979            Some(e) => -e[i],
980            None => consts(&lowered[i]) as f64,
981        }
982    };
983    // A `?s rdf:type <Class>` with an as-yet-unbound subject is a **class
984    // enumeration**: it matches every instance of the class, so it is almost never
985    // the most selective place to *start* a join (a popular class has thousands of
986    // instances), even though it has two bound positions and so looks selective to
987    // the constant-count heuristic. Deprioritize it as a seed; once its subject is
988    // bound by another pattern it becomes a cheap per-row type *check* (the hybrid
989    // join probes it), so pushing it later costs nothing. Robust and stats-free.
990    let type_pid = ctx.rete.dictionary().predicate_id(crate::file::RDF_TYPE);
991    let is_class_enum = |i: usize, bound: &std::collections::HashSet<usize>| -> bool {
992        let t = &lowered[i];
993        !per_class
994            && type_pid.is_some_and(|tp| matches!(t.1, SlotTerm::Pred(p) if p == tp))
995            && matches!(t.2, SlotTerm::Node(_))
996            && matches!(t.0, SlotTerm::Var(v) if !bound.contains(&v))
997    };
998    let n = lowered.len();
999    let mut remaining: Vec<usize> = (0..n).collect();
1000    let mut order: Vec<usize> = Vec::with_capacity(n);
1001    let mut bound: std::collections::HashSet<usize> = seed.clone();
1002    while !remaining.is_empty() {
1003        // Pick the remaining pattern with the best (connected, not-a-class-enum,
1004        // score) key; ties go to the lowest original index for a stable order.
1005        let best = *remaining
1006            .iter()
1007            .max_by(|&&a, &&b| {
1008                let connected = |i: usize| vars(&lowered[i]).iter().any(|v| bound.contains(v));
1009                connected(a)
1010                    .cmp(&connected(b))
1011                    .then_with(|| is_class_enum(b, &bound).cmp(&is_class_enum(a, &bound)))
1012                    .then_with(|| {
1013                        score(a)
1014                            .partial_cmp(&score(b))
1015                            .unwrap_or(std::cmp::Ordering::Equal)
1016                    })
1017                    .then_with(|| b.cmp(&a))
1018            })
1019            .unwrap();
1020        for v in vars(&lowered[best]) {
1021            bound.insert(v);
1022        }
1023        order.push(best);
1024        remaining.retain(|&i| i != best);
1025    }
1026    order
1027}
1028
1029/// Does the BGP have at least one solution against `index`? A single pattern
1030/// with all-distinct variables streams the index and stops at the first match
1031/// (no materialization); anything else falls back to the full evaluator and
1032/// tests non-emptiness (still benefiting from the lazy per-pattern scan). Used
1033/// by `ASK`.
1034pub(crate) fn bgp_exists(ctx: &Ctx, index: &GraphIndex, patterns: &[TriplePattern]) -> bool {
1035    let dict = ctx.rete.dictionary();
1036    let Some(lowered) = lower(patterns, ctx) else {
1037        return false;
1038    };
1039    if let [t] = lowered.as_slice() {
1040        // The fast path can't enforce a variable repeated across positions
1041        // (e.g. `?x p ?x`) — that needs the row builder — so only take it when
1042        // the pattern's variables are all distinct.
1043        let names: Vec<usize> = [&t.0, &t.1, &t.2]
1044            .into_iter()
1045            .filter_map(|x| match x {
1046                SlotTerm::Var(v) => Some(*v),
1047                _ => None,
1048            })
1049            .collect();
1050        let distinct = names
1051            .iter()
1052            .enumerate()
1053            .all(|(i, v)| !names[i + 1..].contains(v));
1054        if distinct {
1055            return match (
1056                const_subject(&t.0, dict),
1057                const_predicate(&t.1),
1058                const_object(&t.2, dict),
1059            ) {
1060                (Some(s), Some(p), Some(o)) => index.scan_iter((s, p, o)).next().is_some(),
1061                _ => false,
1062            };
1063        }
1064    }
1065    !eval_bgp_rows(ctx, index, patterns).is_empty()
1066}
1067
1068/// A lazy left-deep BGP join that yields solution rows one at a time, so a
1069/// consumer under `LIMIT`/`OFFSET` (or `.next()` for ASK) can stop early —
1070/// stopping also stops the underlying index scan.
1071///
1072/// The all-but-last patterns are joined eagerly into a `prefix` **hash table**
1073/// (they are the most selective, so this is the small side); the *last* (least
1074/// selective) pattern is then **streamed** from the index cursor, probing the
1075/// prefix — the big scan is never materialized, and a `LIMIT` above stops it
1076/// after a handful of triples. Yields the same solution multiset as
1077/// [`eval_bgp_rows`] (the join is order-independent), just incrementally.
1078pub(crate) struct BgpSolutions<'q> {
1079    scan: Option<Box<dyn Iterator<Item = Row> + 'q>>,
1080    prefix: Vec<Row>,
1081    /// Prefix row indices keyed by the shared-slot values.
1082    buckets: HashMap<Vec<Val>, Vec<usize>>,
1083    shared: Vec<usize>,
1084    cartesian: bool,
1085    /// The prefix is the all-unbound seed row (single-pattern BGP): scanned
1086    /// rows are already complete solutions — pass them through unmerged.
1087    seed_only: bool,
1088    cur_scan: Option<Row>,
1089    /// Reused candidate buffer (avoids a Vec allocation per scanned row).
1090    matches: Vec<usize>,
1091    mi: usize,
1092}
1093
1094impl<'q> BgpSolutions<'q> {
1095    /// Yield `rows` as complete solutions (no prefix merge).
1096    fn passthrough(rows: Box<dyn Iterator<Item = Row> + 'q>) -> Self {
1097        BgpSolutions {
1098            scan: Some(rows),
1099            prefix: Vec::new(),
1100            buckets: HashMap::new(),
1101            shared: Vec::new(),
1102            cartesian: false,
1103            seed_only: true,
1104            cur_scan: None,
1105            matches: Vec::new(),
1106            mi: 0,
1107        }
1108    }
1109
1110    /// The fully streaming join, for a BGP whose most selective pattern is
1111    /// itself large ([`STREAM_SEED_ROWS`]). Nothing proportional to it is ever
1112    /// held: the seed pattern streams from its index cursor (or, when the next
1113    /// pattern is just as large and shares its subject variable, both stream
1114    /// through a merge join), and each later pattern joins per row — through
1115    /// a hash table when the pattern is small ([`HASH_BUILD_ROWS`]), by an
1116    /// index probe otherwise. Same solution multiset as the materializing
1117    /// path; only the row order differs.
1118    fn streaming(
1119        ctx: &'q Ctx<'q>,
1120        index: &'q GraphIndex,
1121        lowered: &[(SlotTerm, SlotTerm, SlotTerm)],
1122        order: &[usize],
1123        est: &[f64],
1124    ) -> Self {
1125        let (mut stream, mut bound, skip): (Box<dyn Iterator<Item = Row> + 'q>, Vec<usize>, usize) =
1126            match (est[order[1]] > limits().stream_seed)
1127                .then(|| merge_stream(ctx, index, lowered[order[0]], lowered[order[1]]))
1128                .flatten()
1129            {
1130                Some((m, slots)) => (m, slots, 2),
1131                None => {
1132                    let t = lowered[order[0]];
1133                    let Some(scan) = scan_rows(ctx, index, t) else {
1134                        return Self::empty();
1135                    };
1136                    (Box::new(scan), pattern_slots(&t), 1)
1137                }
1138            };
1139        for &i in &order[skip..] {
1140            let t = lowered[i];
1141            let slots = pattern_slots(&t);
1142            let shared: Vec<usize> = slots
1143                .iter()
1144                .copied()
1145                .filter(|s| bound.contains(s))
1146                .collect();
1147            if !shared.is_empty() && est[i] > limits().hash_build {
1148                stream = Box::new(stream.flat_map(move |base| probe_rows(ctx, index, t, base)));
1149            } else {
1150                // Small (or unconnected) pattern: scan it once into a table
1151                // keyed on the shared slots and join every streamed row to it.
1152                let Some((rel, _)) = pattern_rows(ctx, index, &t) else {
1153                    return Self::empty();
1154                };
1155                let mut table: HashMap<Vec<Val>, Vec<Row>> = HashMap::new();
1156                for r in rel {
1157                    let key: Vec<Val> = shared.iter().map(|&s| r[s].clone().unwrap()).collect();
1158                    table.entry(key).or_default().push(r);
1159                }
1160                let fill = slots.clone();
1161                stream = Box::new(stream.flat_map(move |base| {
1162                    let key: Vec<Val> = shared.iter().map(|&s| base[s].clone().unwrap()).collect();
1163                    let out: Vec<Row> = table
1164                        .get(&key)
1165                        .map(|rs| {
1166                            rs.iter()
1167                                .map(|r| {
1168                                    let mut row = base.clone();
1169                                    for &s in &fill {
1170                                        row[s] = r[s].clone();
1171                                    }
1172                                    row
1173                                })
1174                                .collect()
1175                        })
1176                        .unwrap_or_default();
1177                    out.into_iter()
1178                }));
1179            }
1180            for s in slots {
1181                if !bound.contains(&s) {
1182                    bound.push(s);
1183                }
1184            }
1185        }
1186        Self::passthrough(stream)
1187    }
1188
1189    /// An iterator that yields nothing (an unsatisfiable BGP).
1190    fn empty() -> Self {
1191        BgpSolutions {
1192            scan: None,
1193            prefix: Vec::new(),
1194            buckets: HashMap::new(),
1195            shared: Vec::new(),
1196            cartesian: false,
1197            seed_only: false,
1198            cur_scan: None,
1199            matches: Vec::new(),
1200            mi: 0,
1201        }
1202    }
1203
1204    pub(crate) fn new(ctx: &'q Ctx<'q>, index: &'q GraphIndex, patterns: &[TriplePattern]) -> Self {
1205        // An empty BGP has exactly one (empty) solution.
1206        if patterns.is_empty() {
1207            return BgpSolutions {
1208                scan: Some(Box::new(std::iter::once(ctx.slots.empty_row()))),
1209                prefix: vec![ctx.slots.empty_row()],
1210                buckets: HashMap::new(),
1211                shared: Vec::new(),
1212                cartesian: true,
1213                seed_only: true,
1214                cur_scan: None,
1215                matches: Vec::new(),
1216                mi: 0,
1217            };
1218        }
1219        // Lower all patterns; an unknown constant term ⇒ no solutions.
1220        let Some(lowered) = lower(patterns, ctx) else {
1221            return Self::empty();
1222        };
1223        // Join all but the *last* (least selective) pattern eagerly into the
1224        // prefix; a single pattern leaves the seed row as the prefix.
1225        let order = selectivity_order(ctx, index, &lowered);
1226        let scan_est = tile_estimates(ctx, index, &lowered, &std::collections::HashSet::new());
1227        // Even the most selective pattern is big: materializing it as the
1228        // prefix would hold every one of its rows. Stream the whole join.
1229        if order.len() >= 2 && scan_est[order[0]] > limits().stream_seed {
1230            return Self::streaming(ctx, index, &lowered, &order, &scan_est);
1231        }
1232        let (&last_i, prefix_is) = order.split_last().unwrap();
1233        let prefix_pats: Vec<TriplePattern> =
1234            prefix_is.iter().map(|&i| patterns[i].clone()).collect();
1235        let prefix = eval_bgp_rows(ctx, index, &prefix_pats);
1236        if prefix.is_empty() {
1237            return Self::empty();
1238        }
1239
1240        // Hash-join key = slots shared by the prefix and the last pattern (BGP
1241        // rows bind every slot of their pattern set, so the prefix's bound set
1242        // can be read off its first row).
1243        let shared: Vec<usize> = pattern_slots(&lowered[last_i])
1244            .into_iter()
1245            .filter(|&s| prefix[0][s].is_some())
1246            .collect();
1247
1248        // A few prefix rows against a big last pattern: look each one up
1249        // instead of scanning the pattern. `?r :relationSubject <doi> . ?r ?p
1250        // ?o` has four prefix rows and a last pattern that is the whole graph.
1251        if !shared.is_empty() && probe_beats_scan(prefix.len(), scan_est[last_i]) {
1252            let t = lowered[last_i];
1253            if index.is_remote() {
1254                prefetch_rows_for(ctx, index, t, &prefix);
1255            }
1256            let stream = prefix
1257                .into_iter()
1258                .flat_map(move |base| probe_rows(ctx, index, t, base));
1259            return Self::passthrough(Box::new(stream));
1260        }
1261
1262        // Stream the last pattern with the lazy index cursor.
1263        let Some(scan) = scan_rows(ctx, index, lowered[last_i]) else {
1264            return Self::empty();
1265        };
1266        let cartesian = shared.is_empty();
1267        // A single-pattern BGP joins against the all-unbound seed: scanned rows
1268        // are already complete solutions.
1269        let seed_only = prefix.len() == 1 && prefix[0].iter().all(Option::is_none);
1270        let mut buckets: HashMap<Vec<Val>, Vec<usize>> = HashMap::new();
1271        if !cartesian {
1272            for (i, r) in prefix.iter().enumerate() {
1273                let key: Vec<Val> = shared.iter().map(|&s| r[s].clone().unwrap()).collect();
1274                buckets.entry(key).or_default().push(i);
1275            }
1276        }
1277        BgpSolutions {
1278            scan: Some(Box::new(scan)),
1279            prefix,
1280            buckets,
1281            shared,
1282            cartesian,
1283            seed_only,
1284            cur_scan: None,
1285            matches: Vec::new(),
1286            mi: 0,
1287        }
1288    }
1289}
1290
1291impl Iterator for BgpSolutions<'_> {
1292    type Item = Row;
1293
1294    fn next(&mut self) -> Option<Row> {
1295        // Single-pattern fast path: pass scanned rows through unmerged.
1296        if self.seed_only {
1297            return self.scan.as_mut()?.next();
1298        }
1299        loop {
1300            // Emit the next prefix match for the current scanned row.
1301            if self.mi < self.matches.len() {
1302                let pi = self.matches[self.mi];
1303                self.mi += 1;
1304                let mut merged = self.prefix[pi].clone();
1305                for (slot, v) in self.cur_scan.as_ref().unwrap().iter().enumerate() {
1306                    if v.is_some() {
1307                        merged[slot] = v.clone();
1308                    }
1309                }
1310                return Some(merged);
1311            }
1312            // Pull the next row from the index scan and gather its matches
1313            // (into the reused buffer — no allocation per scanned row).
1314            let s = self.scan.as_mut()?.next()?;
1315            self.matches.clear();
1316            if self.cartesian {
1317                self.matches.extend(0..self.prefix.len());
1318            } else {
1319                let key: Vec<Val> = self.shared.iter().map(|&i| s[i].clone().unwrap()).collect();
1320                if let Some(c) = self.buckets.get(&key) {
1321                    self.matches.extend_from_slice(c);
1322                }
1323            }
1324            self.mi = 0;
1325            self.cur_scan = Some(s);
1326        }
1327    }
1328}
1329
1330/// Batch-fault the tiles that probing `t` from each of `rows` will route to
1331/// (remote readers only; see [`GraphIndex::prefetch_probe_tiles`]).
1332fn prefetch_rows_for(
1333    ctx: &Ctx,
1334    index: &GraphIndex,
1335    t: (SlotTerm, SlotTerm, SlotTerm),
1336    rows: &[Row],
1337) {
1338    let pats: Vec<Pattern> = rows
1339        .iter()
1340        .filter_map(|base| {
1341            match (
1342                probe_subject(ctx, &t.0, base),
1343                probe_predicate(ctx, &t.1, base),
1344                probe_object(ctx, &t.2, base),
1345            ) {
1346                (Some(s), Some(p), Some(o)) => Some((s, p, o)),
1347                _ => None,
1348            }
1349        })
1350        .collect();
1351    index.prefetch_probe_tiles(&pats);
1352}
1353
1354/// A **streaming** merge join of two patterns that share exactly one variable,
1355/// in the subject position of both: each side streams from a permutation
1356/// sorted on subject ([`GraphIndex::scan_iter_sorted_on`]) and only the
1357/// current subject's rows are held. Restricted to the subject column because a
1358/// subject's rows are its out-edges, a small run; an object run (every
1359/// instance of a class) can be the whole pattern. `None` when the shape does
1360/// not qualify; the caller then streams the first pattern alone.
1361#[allow(clippy::type_complexity)]
1362fn merge_stream<'q>(
1363    ctx: &'q Ctx<'q>,
1364    index: &'q GraphIndex,
1365    ta: (SlotTerm, SlotTerm, SlotTerm),
1366    tb: (SlotTerm, SlotTerm, SlotTerm),
1367) -> Option<(Box<dyn Iterator<Item = Row> + 'q>, Vec<usize>)> {
1368    let sa = pattern_slots(&ta);
1369    let sb = pattern_slots(&tb);
1370    let shared: Vec<usize> = sa.iter().copied().filter(|s| sb.contains(s)).collect();
1371    let [v] = shared[..] else {
1372        return None;
1373    };
1374    if !matches!(ta.0, SlotTerm::Var(i) if i == v) || !matches!(tb.0, SlotTerm::Var(i) if i == v) {
1375        return None;
1376    }
1377    let dict = ctx.rete.dictionary();
1378    let lower = |t: &(SlotTerm, SlotTerm, SlotTerm)| -> Option<Pattern> {
1379        Some((
1380            const_subject(&t.0, dict)?,
1381            const_predicate(&t.1)?,
1382            const_object(&t.2, dict)?,
1383        ))
1384    };
1385    let side = |t: (SlotTerm, SlotTerm, SlotTerm)| -> Option<KeyedRows<'q>> {
1386        let it = index.scan_iter_sorted_on(lower(&t)?, 0)?;
1387        let keyed: Box<dyn Iterator<Item = (u32, Row)> + 'q> =
1388            Box::new(it.filter_map(move |tri| triple_row(ctx, &t, tri).map(|r| (tri.0, r))));
1389        Some(keyed.peekable())
1390    };
1391    let a = side(ta)?;
1392    let b = side(tb)?;
1393    let mut slots = sa;
1394    for s in sb {
1395        if !slots.contains(&s) {
1396            slots.push(s);
1397        }
1398    }
1399    Some((
1400        Box::new(MergeStream {
1401            a,
1402            b,
1403            ra: Vec::new(),
1404            rb: Vec::new(),
1405            i: 0,
1406            j: 0,
1407        }),
1408        slots,
1409    ))
1410}
1411
1412type KeyedRows<'q> = std::iter::Peekable<Box<dyn Iterator<Item = (u32, Row)> + 'q>>;
1413
1414/// The two-pointer merge behind [`merge_stream`]: `ra`/`rb` hold the current
1415/// equal-key runs and `(i, j)` walks their cross product lazily.
1416struct MergeStream<'q> {
1417    a: KeyedRows<'q>,
1418    b: KeyedRows<'q>,
1419    ra: Vec<Row>,
1420    rb: Vec<Row>,
1421    i: usize,
1422    j: usize,
1423}
1424
1425impl Iterator for MergeStream<'_> {
1426    type Item = Row;
1427
1428    fn next(&mut self) -> Option<Row> {
1429        loop {
1430            while self.i < self.ra.len() {
1431                if self.j < self.rb.len() {
1432                    let j = self.j;
1433                    self.j += 1;
1434                    if let Some(m) = crate::row::merge_rows(&self.ra[self.i], &self.rb[j]) {
1435                        return Some(m);
1436                    }
1437                } else {
1438                    self.i += 1;
1439                    self.j = 0;
1440                }
1441            }
1442            let ka = self.a.peek()?.0;
1443            let kb = self.b.peek()?.0;
1444            match ka.cmp(&kb) {
1445                std::cmp::Ordering::Less => {
1446                    self.a.next();
1447                }
1448                std::cmp::Ordering::Greater => {
1449                    self.b.next();
1450                }
1451                std::cmp::Ordering::Equal => {
1452                    self.ra.clear();
1453                    self.rb.clear();
1454                    while let Some((_, r)) = self.a.next_if(|(k, _)| *k == ka) {
1455                        self.ra.push(r);
1456                    }
1457                    while let Some((_, r)) = self.b.next_if(|(k, _)| *k == ka) {
1458                        self.rb.push(r);
1459                    }
1460                    self.i = 0;
1461                    self.j = 0;
1462                }
1463            }
1464        }
1465    }
1466}
1467
1468// --- index-nested-loop probing ------------------------------------------------
1469//
1470// Under a small, known demand (LIMIT/ASK — see `Ctx::limit_hint`), scanning
1471// every pattern once to hash-join is mostly wasted work: the consumer wants a
1472// handful of rows. The probe path instead streams the seed/first pattern and,
1473// per row, *probes* each next pattern through the index with the row's bound
1474// values substituted as scan constants — so producing k solutions touches
1475// O(k · patterns) index groups instead of every pattern's full extent. Same
1476// solution multiset as the hash join (joins are order-independent); only the
1477// evaluation order differs.
1478
1479/// Index-scan constant for a subject position given a partially-bound row.
1480/// Outer `None` = unsatisfiable for this row; inner `None` = wildcard.
1481fn probe_subject(ctx: &Ctx, t: &SlotTerm, base: &Row) -> Option<Option<u32>> {
1482    let dict = ctx.rete.dictionary();
1483    match t {
1484        SlotTerm::Node(n) => dict.node_as_subject_id(*n).map(Some),
1485        SlotTerm::Pred(_) => None,
1486        SlotTerm::Var(i) => match &base[*i] {
1487            None => Some(None),
1488            Some(Val::Id(v)) if *v >= 0 => dict.node_as_subject_id(*v as u32).map(Some),
1489            // A predicate-tagged or computed value can never be a subject.
1490            Some(_) => None,
1491        },
1492    }
1493}
1494
1495/// Index-scan constant for an object position (see [`probe_subject`]).
1496fn probe_object(ctx: &Ctx, t: &SlotTerm, base: &Row) -> Option<Option<u32>> {
1497    let dict = ctx.rete.dictionary();
1498    match t {
1499        SlotTerm::Node(n) => dict.node_as_object_id(*n).map(Some),
1500        SlotTerm::Pred(_) => None,
1501        SlotTerm::Var(i) => match &base[*i] {
1502            None => Some(None),
1503            Some(Val::Id(v)) if *v >= 0 => dict.node_as_object_id(*v as u32).map(Some),
1504            Some(_) => None,
1505        },
1506    }
1507}
1508
1509/// Index-scan constant for a predicate position (see [`probe_subject`]).
1510fn probe_predicate(ctx: &Ctx, t: &SlotTerm, base: &Row) -> Option<Option<u32>> {
1511    let dict = ctx.rete.dictionary();
1512    match t {
1513        SlotTerm::Pred(p) => Some(Some(*p)),
1514        SlotTerm::Node(_) => None,
1515        SlotTerm::Var(i) => match &base[*i] {
1516            None => Some(None),
1517            Some(Val::Id(v)) if *v < 0 => Some(Some((-v - 1) as u32)),
1518            // Canonicalized to a node id — the term may still be a predicate.
1519            Some(Val::Id(v)) => ctx
1520                .resolver
1521                .term(*v)
1522                .and_then(|t| dict.predicate_id(&t))
1523                .map(Some),
1524            Some(Val::Str(_)) => None,
1525        },
1526    }
1527}
1528
1529/// Probe one pattern with a partially-bound row: every bound variable becomes
1530/// an index-scan constant, and each matching triple extends a clone of the row
1531/// (repeated unbound variables stay consistent).
1532fn probe_rows<'q>(
1533    ctx: &'q Ctx<'q>,
1534    index: &'q GraphIndex,
1535    t: (SlotTerm, SlotTerm, SlotTerm),
1536    base: Row,
1537) -> Box<dyn Iterator<Item = Row> + 'q> {
1538    let (Some(sid), Some(pid), Some(oid)) = (
1539        probe_subject(ctx, &t.0, &base),
1540        probe_predicate(ctx, &t.1, &base),
1541        probe_object(ctx, &t.2, &base),
1542    ) else {
1543        return Box::new(std::iter::empty());
1544    };
1545    let dict = ctx.rete.dictionary();
1546    Box::new(
1547        index
1548            .scan_iter((sid, pid, oid))
1549            .filter_map(move |(s_id, p_id, o_id)| {
1550                let s_val = dict.subject_node(s_id) as i64;
1551                let p_val = pred_val(ctx, &t.1, p_id);
1552                let o_val = dict.object_node(o_id) as i64;
1553                let mut row = base.clone();
1554                for (term, val) in [(&t.0, s_val), (&t.1, p_val), (&t.2, o_val)] {
1555                    if let SlotTerm::Var(i) = term {
1556                        match row[*i] {
1557                            Some(Val::Id(existing)) if existing != val => return None,
1558                            Some(Val::Id(_)) => {}
1559                            Some(Val::Str(_)) => return None,
1560                            None => row[*i] = Some(Val::Id(val)),
1561                        }
1562                    }
1563                }
1564                Some(row)
1565            }),
1566    )
1567}
1568
1569/// A lowered, probe-ordered BGP, reusable across many seed rows.
1570pub(crate) struct ProbePlan {
1571    pats: Vec<(SlotTerm, SlotTerm, SlotTerm)>,
1572}
1573
1574impl ProbePlan {
1575    /// Lower and order `patterns` for probing from rows that bind (at least)
1576    /// the slots in `seed_mask`. `None` = a constant term is unknown, making
1577    /// the BGP unsatisfiable for every seed.
1578    pub(crate) fn new(
1579        ctx: &Ctx,
1580        index: &GraphIndex,
1581        patterns: &[TriplePattern],
1582        seed_mask: &[bool],
1583    ) -> Option<Self> {
1584        let lowered = lower(patterns, ctx)?;
1585        let seed: std::collections::HashSet<usize> = seed_mask
1586            .iter()
1587            .enumerate()
1588            .filter_map(|(i, b)| b.then_some(i))
1589            .collect();
1590        let order = selectivity_order_seeded(ctx, index, &lowered, &seed);
1591        Some(ProbePlan {
1592            pats: order.into_iter().map(|i| lowered[i]).collect(),
1593        })
1594    }
1595}
1596
1597/// Depth-first index-nested-loop join over a [`ProbePlan`], starting from a
1598/// seed row. Fully lazy: pulling k rows probes O(k · patterns) index groups.
1599pub(crate) struct ProbeJoin<'q> {
1600    ctx: &'q Ctx<'q>,
1601    index: &'q GraphIndex,
1602    pats: Vec<(SlotTerm, SlotTerm, SlotTerm)>,
1603    stack: Vec<Box<dyn Iterator<Item = Row> + 'q>>,
1604}
1605
1606impl<'q> ProbeJoin<'q> {
1607    /// Probe a whole BGP from scratch (the seed is the all-unbound row).
1608    /// `None` = unsatisfiable.
1609    pub(crate) fn new(
1610        ctx: &'q Ctx<'q>,
1611        index: &'q GraphIndex,
1612        patterns: &[TriplePattern],
1613    ) -> Option<Self> {
1614        let plan = ProbePlan::new(ctx, index, patterns, &vec![false; ctx.slots.len()])?;
1615        Some(Self::from_plan(ctx, index, &plan, ctx.slots.empty_row()))
1616    }
1617
1618    /// Probe a pre-lowered plan from one seed row.
1619    pub(crate) fn from_plan(
1620        ctx: &'q Ctx<'q>,
1621        index: &'q GraphIndex,
1622        plan: &ProbePlan,
1623        seed: Row,
1624    ) -> Self {
1625        let pats = plan.pats.clone();
1626        let first = probe_rows(ctx, index, pats[0], seed);
1627        ProbeJoin {
1628            ctx,
1629            index,
1630            pats,
1631            stack: vec![first],
1632        }
1633    }
1634}
1635
1636impl Iterator for ProbeJoin<'_> {
1637    type Item = Row;
1638
1639    fn next(&mut self) -> Option<Row> {
1640        loop {
1641            let depth = self.stack.len();
1642            match self.stack.last_mut()?.next() {
1643                Some(row) => {
1644                    if depth == self.pats.len() {
1645                        return Some(row);
1646                    }
1647                    let it = probe_rows(self.ctx, self.index, self.pats[depth], row);
1648                    self.stack.push(it);
1649                }
1650                None => {
1651                    self.stack.pop();
1652                    if self.stack.is_empty() {
1653                        return None;
1654                    }
1655                }
1656            }
1657        }
1658    }
1659}
1660
1661/// Hash-join two BGP relations on the slots they share. Every left row binds
1662/// exactly the slots accumulated so far and every right row binds the current
1663/// pattern's slots, so the shared set is uniform across rows.
1664fn hash_join(
1665    left: Vec<Row>,
1666    left_bound: &[usize],
1667    right: Vec<Row>,
1668    right_slots: &[usize],
1669) -> Vec<Row> {
1670    // Fast path: the all-unbound seed row joins to `right` unchanged.
1671    if left.len() == 1 && left_bound.is_empty() {
1672        return right;
1673    }
1674    if left.is_empty() || right.is_empty() {
1675        return Vec::new();
1676    }
1677    let shared: Vec<usize> = right_slots
1678        .iter()
1679        .copied()
1680        .filter(|s| left_bound.contains(s))
1681        .collect();
1682    let fill = |l: &Row, r: &Row| -> Row {
1683        let mut out = l.clone();
1684        for &s in right_slots {
1685            out[s] = r[s].clone();
1686        }
1687        out
1688    };
1689    if shared.is_empty() {
1690        // No shared variable: Cartesian product. In a connected BGP this only
1691        // arises for a fully-ground pattern (deduped index ⇒ one matching row),
1692        // so it acts as an existence filter rather than multiplying rows.
1693        let mut out = Vec::with_capacity(left.len() * right.len());
1694        for l in &left {
1695            for r in &right {
1696                out.push(fill(l, r));
1697            }
1698        }
1699        return out;
1700    }
1701    let key_of = |b: &Row| -> Vec<Val> { shared.iter().map(|&s| b[s].clone().unwrap()).collect() };
1702    // A hash join is symmetric on the shared key, so build the **smaller** side's
1703    // hash table and probe with the larger — same result set, fewer inserts and a
1704    // smaller table. (The old code always built `right`; after cardinality-based
1705    // ordering the accumulating `left` is usually the small selective side, so
1706    // building it instead is the common win.) Whichever side is built, the output
1707    // row carries every bound slot of both.
1708    let mut out = Vec::new();
1709    if right.len() <= left.len() {
1710        let mut buckets: HashMap<Vec<Val>, Vec<Row>> = HashMap::new();
1711        for r in right {
1712            buckets.entry(key_of(&r)).or_default().push(r);
1713        }
1714        for l in &left {
1715            if let Some(rs) = buckets.get(&key_of(l)) {
1716                for r in rs {
1717                    out.push(fill(l, r));
1718                }
1719            }
1720        }
1721    } else {
1722        // Build the left side; fill the left's own slots into each probed right.
1723        let mut buckets: HashMap<Vec<Val>, Vec<Row>> = HashMap::new();
1724        for l in left {
1725            buckets.entry(key_of(&l)).or_default().push(l);
1726        }
1727        for r in &right {
1728            if let Some(ls) = buckets.get(&key_of(r)) {
1729                for l in ls {
1730                    let mut row = r.clone();
1731                    for &s in left_bound {
1732                        if let Some(v) = &l[s] {
1733                            row[s] = Some(v.clone());
1734                        }
1735                    }
1736                    out.push(row);
1737                }
1738            }
1739        }
1740    }
1741    out
1742}
1743
1744// Constant-only index constraints. A variable scans as a wildcard (`Some(None)`)
1745// — it is resolved later by the hash join, not pushed into the scan. The outer
1746// `None` means "unsatisfiable" (a constant in an impossible role, or unknown to
1747// the dictionary), which empties the whole BGP. Inner `None` = "wildcard".
1748fn const_subject(t: &SlotTerm, d: &crate::Dictionary) -> Option<Option<u32>> {
1749    match t {
1750        SlotTerm::Node(n) => d.node_as_subject_id(*n).map(Some),
1751        SlotTerm::Pred(_) => None, // a predicate term can't be a subject
1752        SlotTerm::Var(_) => Some(None),
1753    }
1754}
1755
1756fn const_object(t: &SlotTerm, d: &crate::Dictionary) -> Option<Option<u32>> {
1757    match t {
1758        SlotTerm::Node(n) => d.node_as_object_id(*n).map(Some),
1759        SlotTerm::Pred(_) => None,
1760        SlotTerm::Var(_) => Some(None),
1761    }
1762}
1763
1764fn const_predicate(t: &SlotTerm) -> Option<Option<u32>> {
1765    match t {
1766        SlotTerm::Pred(p) => Some(Some(*p)),
1767        SlotTerm::Node(_) => None, // a node term can't be a predicate
1768        SlotTerm::Var(_) => Some(None),
1769    }
1770}
1771
1772#[cfg(test)]
1773mod tests {
1774    use super::*;
1775    use crate::dictionary::DictionaryBuilder;
1776    use crate::file::write_file;
1777    use crate::index::GraphIndexBuilder;
1778
1779    fn rete_from(triples: &[(&str, &str, &str)]) -> Vec<u8> {
1780        let mut db = DictionaryBuilder::new();
1781        for (s, p, o) in triples {
1782            db.observe(s, p, o);
1783        }
1784        let dict = db.build();
1785        let mut ib = GraphIndexBuilder::new();
1786        for (s, p, o) in triples {
1787            ib.push(dict.encode(s, p, o).unwrap());
1788        }
1789        write_file(&dict, &ib.build(), false, &[], 0)
1790    }
1791
1792    fn pat(s: &str, p: &str, o: &str) -> TriplePattern {
1793        TriplePattern {
1794            s: PatternTerm::parse(s),
1795            p: PatternTerm::parse(p),
1796            o: PatternTerm::parse(o),
1797        }
1798    }
1799
1800    /// The scan-bytes figure (`rete cost`'s preview, and through 0.3.7 the
1801    /// merge-join and fat-side gates) routes a bound term by its id IN ITS
1802    /// ROLE. It used to route the unified node id,
1803    /// which for an object-only term (any literal) is shifted past the whole
1804    /// subject-only block: `?s :value "lit"` found no OPS tile at all, cost
1805    /// "0 bytes", and so let a merge join materialize its partner in full —
1806    /// on OpenCitations, every DOI identifier of a 2.9 B-triple file, behind
1807    /// `?id :hasLiteralValue "10.…" ; :usesIdentifierScheme :doi`.
1808    #[test]
1809    fn scan_bytes_route_a_literal_by_its_object_id() {
1810        let value = "<http://ex/value>";
1811        let owned: Vec<(String, String)> = (0..400)
1812            .map(|i| (format!("<http://ex/s{i:03}>"), format!("\"lit {i:03}\"")))
1813            .collect();
1814        let mut db = DictionaryBuilder::new();
1815        for (s, o) in &owned {
1816            db.observe(s, value, o);
1817        }
1818        let dict = db.build();
1819        let mut ib = GraphIndexBuilder::new().with_tile_budget(256);
1820        for (s, o) in &owned {
1821            ib.push(dict.encode(s, value, o).unwrap());
1822        }
1823        let rete = Rete::open(&write_file(&dict, &ib.build(), false, &[], 0)).unwrap();
1824        let index = rete.default_index();
1825        // The whole predicate, the most a bound object could ever cost.
1826        let whole = routed_scan_bytes(index, (None, rete.dictionary().predicate_id(value), None));
1827        for (_, lit) in owned.iter().step_by(57) {
1828            let q = [pat("?s", value, lit)];
1829            let mut slots = Slots::new();
1830            collect_pattern_slots(&q, &mut slots);
1831            let ctx = Ctx::new(&rete, slots);
1832            let got = bgp_min_scan_bytes(&ctx, index, &q).unwrap();
1833            assert!(got > 0, "{lit} routed to no tile");
1834            assert!(
1835                got < whole,
1836                "{lit} routed to the whole predicate ({got} of {whole})"
1837            );
1838        }
1839    }
1840
1841    /// The planner's size gates read ROW estimates, which do not depend on how
1842    /// the tiles are compressed. The same file opened resident (tile sizes are
1843    /// the decoded images) and lazily (tile sizes are the compressed frames)
1844    /// reports different bytes but must take the same decisions — the byte
1845    /// gates this replaced flipped plans when only the zstd level changed.
1846    #[test]
1847    fn plan_gates_do_not_move_with_the_compression() {
1848        let mut owned: Vec<(String, String, String)> = Vec::new();
1849        for i in 0..3000 {
1850            let s = format!("<http://ex/s{i}>");
1851            owned.push((
1852                s.clone(),
1853                "<http://ex/kind>".into(),
1854                format!("<http://ex/K{}>", i % 7),
1855            ));
1856            owned.push((s.clone(), "<http://ex/value>".into(), format!("\"v {i}\"")));
1857            if i % 50 == 0 {
1858                owned.push((s, "<http://ex/rare>".into(), "<http://ex/R>".into()));
1859            }
1860        }
1861        let mut db = DictionaryBuilder::new();
1862        for (s, p, o) in &owned {
1863            db.observe(s, p, o);
1864        }
1865        let dict = db.build();
1866        let mut ib = GraphIndexBuilder::new().with_tile_budget(1024);
1867        for (s, p, o) in &owned {
1868            ib.push(dict.encode(s, p, o).unwrap());
1869        }
1870        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
1871        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
1872        let resident = Rete::open(&bytes).unwrap();
1873        let lazy = Rete::open_ranged_lazy(crate::reader::SliceReader::new(leaked)).unwrap();
1874        let shapes: Vec<Vec<TriplePattern>> = vec![
1875            vec![
1876                pat("?s", "<http://ex/rare>", "<http://ex/R>"),
1877                pat("?s", "<http://ex/value>", "?v"),
1878            ],
1879            vec![
1880                pat("?s", "<http://ex/kind>", "<http://ex/K3>"),
1881                pat("?s", "<http://ex/value>", "?v"),
1882            ],
1883            vec![
1884                pat("?s", "<http://ex/kind>", "?k"),
1885                pat("?s", "<http://ex/value>", "?v"),
1886            ],
1887            vec![
1888                pat("?s", "<http://ex/value>", "\"v 7\""),
1889                pat("?s", "<http://ex/kind>", "?k"),
1890            ],
1891        ];
1892        let decisions = |rete: &Rete| -> Vec<(Vec<u64>, bool, Option<u64>)> {
1893            shapes
1894                .iter()
1895                .map(|q| {
1896                    let mut slots = Slots::new();
1897                    collect_pattern_slots(q, &mut slots);
1898                    let ctx = Ctx::new(rete, slots);
1899                    let index = rete.default_index();
1900                    let lowered = lower(q, &ctx).unwrap();
1901                    let est = tile_estimates(&ctx, index, &lowered, &Default::default());
1902                    let lopsided = merge_seed_lopsided(&ctx, index, &lowered[0], &lowered[1]);
1903                    let min = bgp_min_estimate(&ctx, index, q).map(|e| e as u64);
1904                    (est.iter().map(|e| *e as u64).collect(), lopsided, min)
1905                })
1906                .collect()
1907        };
1908        assert_eq!(decisions(&resident), decisions(&lazy));
1909        // ... while the byte figures the gates used to read do differ.
1910        #[cfg(feature = "compression")]
1911        {
1912            let bytes_of = |rete: &Rete| {
1913                let q = &shapes[2];
1914                let mut slots = Slots::new();
1915                collect_pattern_slots(q, &mut slots);
1916                let ctx = Ctx::new(rete, slots);
1917                bgp_min_scan_bytes(&ctx, rete.default_index(), q)
1918            };
1919            assert_ne!(bytes_of(&resident), bytes_of(&lazy));
1920        }
1921    }
1922
1923    /// Every join path the planner can pick — materialized prefix, probed last
1924    /// pattern, streamed seed with hashed or probed later patterns, streaming
1925    /// merge join — returns the same solution multiset. The thresholds are
1926    /// shrunk so a 400-triple graph takes each path.
1927    #[test]
1928    fn every_join_path_returns_the_same_solutions() {
1929        let mut owned: Vec<(String, String, String)> = Vec::new();
1930        let mut x: u64 = 7;
1931        let mut next = |m: u64| {
1932            x = x
1933                .wrapping_mul(6364136223846793005)
1934                .wrapping_add(1442695040888963407);
1935            (x >> 33) % m
1936        };
1937        for i in 0..60 {
1938            let s = format!("s{i}");
1939            owned.push((s.clone(), "a".into(), format!("C{}", next(3))));
1940            for p in 0..4 {
1941                for _ in 0..next(3) {
1942                    owned.push((s.clone(), format!("p{p}"), format!("s{}", next(60))));
1943                }
1944            }
1945        }
1946        let triples: Vec<(&str, &str, &str)> = owned
1947            .iter()
1948            .map(|(s, p, o)| (s.as_str(), p.as_str(), o.as_str()))
1949            .collect();
1950        let bytes = rete_from(&triples);
1951        let rete = Rete::open(&bytes).unwrap();
1952        let queries: Vec<Vec<TriplePattern>> = vec![
1953            vec![pat("?s", "p0", "?a"), pat("?s", "p1", "?b")],
1954            vec![pat("?s", "a", "C1"), pat("?s", "p0", "?a")],
1955            vec![pat("?s", "p0", "?a"), pat("?a", "p2", "?b")],
1956            vec![pat("s5", "p0", "?a"), pat("?a", "?p", "?o")],
1957            vec![pat("?s", "p0", "?x"), pat("?s", "p1", "?x")],
1958            vec![
1959                pat("?s", "p3", "?o"),
1960                pat("?o", "a", "C0"),
1961                pat("?s", "p1", "?z"),
1962            ],
1963            vec![
1964                pat("?s", "p0", "?a"),
1965                pat("?s", "p1", "?b"),
1966                pat("?b", "p2", "?c"),
1967            ],
1968        ];
1969        let run = |limits: Option<PlanLimits>| -> Vec<Vec<Vec<(String, String)>>> {
1970            TEST_LIMITS.with(|c| c.set(limits));
1971            let out = queries
1972                .iter()
1973                .map(|q| {
1974                    let mut slots = crate::row::Slots::new();
1975                    collect_pattern_slots(q, &mut slots);
1976                    let ctx = Ctx::new(&rete, slots);
1977                    let mut rows: Vec<Vec<(String, String)>> =
1978                        BgpSolutions::new(&ctx, rete.default_index(), q)
1979                            .map(|r| row_to_binding(&ctx, &r).into_iter().collect())
1980                            .collect();
1981                    rows.sort();
1982                    rows
1983                })
1984                .collect();
1985            TEST_LIMITS.with(|c| c.set(None));
1986            out
1987        };
1988        let reference = run(None);
1989        assert!(reference.iter().all(|r| !r.is_empty()), "{reference:?}");
1990        for (name, l) in [
1991            (
1992                "probe everything",
1993                PlanLimits {
1994                    probe_floor: 0.0,
1995                    stream_seed: f64::MAX,
1996                    hash_build: f64::MAX,
1997                },
1998            ),
1999            (
2000                "stream + hash",
2001                PlanLimits {
2002                    probe_floor: f64::MAX,
2003                    stream_seed: 0.0,
2004                    hash_build: f64::MAX,
2005                },
2006            ),
2007            (
2008                "stream + probe",
2009                PlanLimits {
2010                    probe_floor: f64::MAX,
2011                    stream_seed: 0.0,
2012                    hash_build: 0.0,
2013                },
2014            ),
2015            (
2016                "stream, all paths",
2017                PlanLimits {
2018                    probe_floor: 0.0,
2019                    stream_seed: 0.0,
2020                    hash_build: 30.0,
2021                },
2022            ),
2023        ] {
2024            assert_eq!(run(Some(l)), reference, "{name}");
2025        }
2026    }
2027
2028    #[test]
2029    fn single_pattern_binds_variable() {
2030        let bytes = rete_from(&[("Alice", "knows", "Bob"), ("Bob", "knows", "Carol")]);
2031        let rete = Rete::open(&bytes).unwrap();
2032        let sols = eval_bgp(&rete, &[pat("Alice", "knows", "?y")]);
2033        assert_eq!(sols.len(), 1);
2034        assert_eq!(sols[0]["y"], "Bob");
2035    }
2036
2037    #[test]
2038    fn two_hop_join_on_shared_variable() {
2039        // Alice -> Bob -> Carol, plus a dead-end branch.
2040        let bytes = rete_from(&[
2041            ("Alice", "knows", "Bob"),
2042            ("Bob", "knows", "Carol"),
2043            ("Carol", "knows", "Dave"),
2044            ("Alice", "knows", "Eve"), // Eve knows no one -> no 2-hop
2045        ]);
2046        let rete = Rete::open(&bytes).unwrap();
2047        let sols = eval_bgp(&rete, &[pat("?x", "knows", "?y"), pat("?y", "knows", "?z")]);
2048        // Alice-Bob-Carol, Bob-Carol-Dave.
2049        let mut got: Vec<_> = sols
2050            .iter()
2051            .map(|b| (b["x"].clone(), b["y"].clone(), b["z"].clone()))
2052            .collect();
2053        got.sort();
2054        assert_eq!(
2055            got,
2056            vec![
2057                ("Alice".into(), "Bob".into(), "Carol".into()),
2058                ("Bob".into(), "Carol".into(), "Dave".into()),
2059            ]
2060        );
2061    }
2062
2063    #[test]
2064    fn repeated_variable_within_pattern() {
2065        // A mutual/self relation: only Bob knows himself.
2066        let bytes = rete_from(&[("Alice", "knows", "Bob"), ("Bob", "knows", "Bob")]);
2067        let rete = Rete::open(&bytes).unwrap();
2068        let sols = eval_bgp(&rete, &[pat("?x", "knows", "?x")]);
2069        assert_eq!(sols.len(), 1);
2070        assert_eq!(sols[0]["x"], "Bob");
2071    }
2072
2073    #[test]
2074    fn no_solutions_yields_empty() {
2075        let bytes = rete_from(&[("Alice", "knows", "Bob")]);
2076        let rete = Rete::open(&bytes).unwrap();
2077        assert!(eval_bgp(&rete, &[pat("Alice", "likes", "?y")]).is_empty());
2078    }
2079
2080    /// A LUBM-Q7-shaped snowflake: a popular class enumeration (`?x a Student`)
2081    /// plus a selective adjacency seed (`<Prof> teacherOf ?y`). Exercises both the
2082    /// **class-enum seed deprioritization** (the join must not start from the huge
2083    /// `a Student` extent) and the **probe-the-tail hybrid** (the type checks are
2084    /// applied late, per row). The answer must equal a brute-force computation
2085    /// regardless of how the join is ordered/probed.
2086    #[test]
2087    fn snowflake_type_and_adjacency_matches_reference() {
2088        const TYPE: &str = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>";
2089        let mut triples: Vec<(String, String, String)> = Vec::new();
2090        // 60 students, each a Student and taking 2 courses (c{i%5}, c{(i+1)%5}).
2091        for i in 0..60 {
2092            let s = format!("S{i}");
2093            triples.push((s.clone(), TYPE.into(), "Student".into()));
2094            triples.push((s.clone(), "takesCourse".into(), format!("c{}", i % 5)));
2095            triples.push((s, "takesCourse".into(), format!("c{}", (i + 1) % 5)));
2096        }
2097        // 5 courses; the professor teaches only c0 and c2.
2098        for j in 0..5 {
2099            triples.push((format!("c{j}"), TYPE.into(), "Course".into()));
2100        }
2101        triples.push(("Prof".into(), "teacherOf".into(), "c0".into()));
2102        triples.push(("Prof".into(), "teacherOf".into(), "c2".into()));
2103
2104        let refs: Vec<(&str, &str, &str)> = triples
2105            .iter()
2106            .map(|(s, p, o)| (s.as_str(), p.as_str(), o.as_str()))
2107            .collect();
2108        let bytes = rete_from(&refs);
2109        let rete = Rete::open(&bytes).unwrap();
2110
2111        let got: std::collections::BTreeSet<(String, String)> = eval_bgp(
2112            &rete,
2113            &[
2114                pat("?x", TYPE, "Student"),
2115                pat("?y", TYPE, "Course"),
2116                pat("?x", "takesCourse", "?y"),
2117                pat("Prof", "teacherOf", "?y"),
2118            ],
2119        )
2120        .into_iter()
2121        .map(|b| (b["x"].clone(), b["y"].clone()))
2122        .collect();
2123
2124        // Brute force: students taking a course the professor teaches (c0 or c2).
2125        let taught = ["c0", "c2"];
2126        let mut want = std::collections::BTreeSet::new();
2127        for i in 0..60 {
2128            for c in [i % 5, (i + 1) % 5] {
2129                let course = format!("c{c}");
2130                if taught.contains(&course.as_str()) {
2131                    want.insert((format!("S{i}"), course));
2132                }
2133            }
2134        }
2135        assert_eq!(got, want, "snowflake join must match the brute-force set");
2136        assert!(!want.is_empty(), "sanity: the reference set is non-empty");
2137    }
2138
2139    /// A subject-subject join (`?x a ?av . ?x b ?bv`) is the same-role shape the
2140    /// merge join handles: both patterns sort on the subject column. With multiple
2141    /// `a` and `b` values per subject it must emit the full cross product per
2142    /// subject (equal-key run handling) and drop subjects missing either side.
2143    #[test]
2144    fn merge_join_subject_star_cross_product() {
2145        let bytes = rete_from(&[
2146            ("x", "a", "a1"),
2147            ("x", "a", "a2"),
2148            ("x", "b", "b1"),
2149            ("x", "b", "b2"),
2150            ("y", "a", "a3"),
2151            ("y", "b", "b3"),
2152            ("z", "a", "a4"), // z has no `b` → contributes no join row
2153        ]);
2154        let rete = Rete::open(&bytes).unwrap();
2155        let mut got: Vec<(String, String, String)> =
2156            eval_bgp(&rete, &[pat("?x", "a", "?av"), pat("?x", "b", "?bv")])
2157                .iter()
2158                .map(|m| (m["x"].clone(), m["av"].clone(), m["bv"].clone()))
2159                .collect();
2160        got.sort();
2161        let mut want: Vec<(String, String, String)> = [
2162            ("x", "a1", "b1"),
2163            ("x", "a1", "b2"),
2164            ("x", "a2", "b1"),
2165            ("x", "a2", "b2"),
2166            ("y", "a3", "b3"),
2167        ]
2168        .into_iter()
2169        .map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string()))
2170        .collect();
2171        want.sort();
2172        assert_eq!(got, want, "same-role merge must equal the brute-force join");
2173    }
2174}