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/// Build a solution row for one scanned triple, enforcing repeated variables
660/// *within* the pattern (e.g. `?x p ?x`). `None` = the triple doesn't satisfy
661/// a repeated variable.
662fn triple_row(
663    ctx: &Ctx,
664    t: &(SlotTerm, SlotTerm, SlotTerm),
665    (s_id, p_id, o_id): (u32, u32, u32),
666) -> Option<Row> {
667    let dict = ctx.rete.dictionary();
668    let s_val = dict.subject_node(s_id) as i64;
669    let p_val = ctx.resolver.canon_id(pred_tag(p_id));
670    let o_val = dict.object_node(o_id) as i64;
671    let mut row = ctx.slots.empty_row();
672    for (term, val) in [(&t.0, s_val), (&t.1, p_val), (&t.2, o_val)] {
673        if let SlotTerm::Var(i) = term {
674            match row[*i] {
675                Some(Val::Id(existing)) if existing != val => return None,
676                Some(_) => {}
677                None => row[*i] = Some(Val::Id(val)),
678            }
679        }
680    }
681    Some(row)
682}
683
684/// Lazily scan one lowered pattern as a stream of solution rows. The scan
685/// constrains only the pattern's constant terms (bound variables are joined in
686/// afterwards, not pushed into the scan). `None` = a constant is unsatisfiable
687/// (unknown to the dictionary or in an impossible role), which empties the BGP.
688fn scan_rows<'q>(
689    ctx: &'q Ctx<'q>,
690    index: &'q GraphIndex,
691    t: (SlotTerm, SlotTerm, SlotTerm),
692) -> Option<impl Iterator<Item = Row> + 'q> {
693    let dict = ctx.rete.dictionary();
694    let (sid, pid, oid) = (
695        const_subject(&t.0, dict)?,
696        const_predicate(&t.1)?,
697        const_object(&t.2, dict)?,
698    );
699    Some(
700        index
701            .scan_iter((sid, pid, oid))
702            .filter_map(move |triple| triple_row(ctx, &t, triple)),
703    )
704}
705
706/// Scan one lowered pattern into a materialized relation, returning the rows
707/// and the slots the pattern binds. `None` = a constant is unsatisfiable.
708fn pattern_rows(
709    ctx: &Ctx,
710    index: &GraphIndex,
711    t: &(SlotTerm, SlotTerm, SlotTerm),
712) -> Option<(Vec<Row>, Vec<usize>)> {
713    let slots = pattern_slots(t);
714    let mut rel: Vec<Row> = Vec::new();
715    let dict = ctx.rete.dictionary();
716    let (sid, pid, oid) = (
717        const_subject(&t.0, dict)?,
718        const_predicate(&t.1)?,
719        const_object(&t.2, dict)?,
720    );
721    // Stream the matches with the lazy cursor — the hash join is
722    // order-independent, so no canonical re-sort is needed here.
723    for triple in index.scan_iter((sid, pid, oid)) {
724        if let Some(row) = triple_row(ctx, t, triple) {
725            rel.push(row);
726        }
727    }
728    Some((rel, slots))
729}
730
731/// A per-pattern cardinality estimate, smaller = cheaper to scan. The base is
732/// the **exact** per-predicate triple count (sum of the summary quotient graph's
733/// super-edge counts) when the predicate is bound, else the whole-graph total.
734/// A bound subject/object (a constant, or a variable already bound by `seed`)
735/// then scales it down by that predicate's **measured selectivity** from the
736/// `query_stats` block — `1 / distinct_subjects` for a bound subject (so
737/// `<s> <p> ?o` ≈ the average objects per subject; exactly 1 for a functional
738/// predicate), `1 / distinct_objects` for a bound object. Files built before the
739/// `query_stats` block fall back to fixed default selectivities. `None` when the
740/// file carries no pyramid summary at all — ordering then uses the constant-count
741/// heuristic.
742fn pattern_estimates(
743    ctx: &Ctx,
744    lowered: &[(SlotTerm, SlotTerm, SlotTerm)],
745    seed: &std::collections::HashSet<usize>,
746) -> Option<Vec<f64>> {
747    // Only use the summary when it's already in memory — never fault it just to
748    // plan (the lazy remote path defers the pyramid by design).
749    let pyr = ctx.rete.pyramid_if_loaded()?;
750    let mut pred: std::collections::HashMap<u32, u64> = std::collections::HashMap::new();
751    for e in &pyr.summary {
752        *pred.entry(e.predicate).or_insert(0) += e.count as u64;
753    }
754    // Measured per-predicate distinct subjects/objects: the SCHEMA section
755    // (kind 9) — where a file built since it existed keeps them, its
756    // pyramid-meta holding only the community summary — else the pyramid-meta's
757    // query-stats block; empty on files built before either → the default
758    // selectivities. The pyramid is resident here, so the (small) section is
759    // too on a resident open, and costs one range once on a lazy one.
760    let stats: std::collections::HashMap<u32, &crate::meta::PredStat> = ctx
761        .rete
762        .planner_stats(true)
763        .iter()
764        .map(|s| (s.predicate, s))
765        .collect();
766    let total = ctx.rete.header().quad_count.max(1) as f64;
767    let num_preds = pred.len().max(1) as f64;
768    // Defaults used only when query_stats has no entry for the predicate.
769    const SEL_SUBJECT: f64 = 0.001;
770    const SEL_OBJECT: f64 = 0.02;
771    let node_bound = |t: &SlotTerm| match t {
772        SlotTerm::Node(_) => true,
773        SlotTerm::Var(v) => seed.contains(v),
774        SlotTerm::Pred(_) => false,
775    };
776    Some(
777        lowered
778            .iter()
779            .map(|t| {
780                // Base from the predicate: exact total for a constant predicate,
781                // the average predicate total for a seed-bound predicate variable
782                // (the value is known to the probe but not at plan time), else the
783                // whole graph. `st` is the measured stats for a constant predicate.
784                let (base, st) = match t.1 {
785                    SlotTerm::Pred(p) => {
786                        (*pred.get(&p).unwrap_or(&0) as f64, stats.get(&p).copied())
787                    }
788                    SlotTerm::Var(v) if seed.contains(&v) => (total / num_preds, None),
789                    _ => (total, None),
790                };
791                let mut est = base.max(1.0);
792                if node_bound(&t.0) {
793                    est *= match st {
794                        Some(s) if s.distinct_subjects > 0 => 1.0 / s.distinct_subjects as f64,
795                        _ => SEL_SUBJECT,
796                    };
797                }
798                if node_bound(&t.2) {
799                    est *= match st {
800                        Some(s) if s.distinct_objects > 0 => 1.0 / s.distinct_objects as f64,
801                        _ => SEL_OBJECT,
802                    };
803                }
804                est.max(1.0)
805            })
806            .collect(),
807    )
808}
809
810/// The minimum, over a BGP's patterns, of [`pattern_scan_bytes`] — the
811/// cheapest single-pattern scan the hash-join path would at least perform to
812/// materialize this BGP. `None` = a constant term is unknown to the
813/// dictionary (the BGP is unsatisfiable; the caller short-circuits anyway).
814/// Unlike [`pattern_estimates`], this needs no pyramid summary, so it works on
815/// remote-lazy files — exactly where the answer matters most.
816pub(crate) fn bgp_min_scan_bytes(
817    ctx: &Ctx,
818    index: &GraphIndex,
819    patterns: &[TriplePattern],
820) -> Option<u64> {
821    let lowered = lower(patterns, ctx)?;
822    lowered
823        .iter()
824        .map(|t| pattern_scan_bytes(ctx, index, t))
825        .min()
826}
827
828/// One pattern's cheapest scan extent in BYTES — what `rete cost` previews
829/// (the planner's gates use row estimates instead): for each permutation, sum the
830/// encoded lengths of the tiles a scan of this pattern would route to (all
831/// tiles covering the bound leading key, or the whole section when the leading
832/// key is unbound), and take the minimum. Tile *counts* are useless here — a
833/// broad predicate can sit inside one giant tile — but the directory's byte
834/// lengths expose it. Reads only resident metadata, never tile data.
835///
836/// A bound term routes by its id **in the role its position has**: a tile's
837/// leading range is in subject-, predicate- or object-id space, not in the
838/// unified node space a lowered constant carries. Routing the node id instead
839/// was off by one for subjects and off by the whole subject-only block for an
840/// object-only term (every literal), so `?id :value "10.1234/x"` could cost
841/// "0 bytes" and pass the merge-join gate against a fat partner, which the
842/// merge then materialized in full. A constant that has no id in the role
843/// (never a subject, say) matches nothing: 0 bytes is then exact.
844fn pattern_scan_bytes(ctx: &Ctx, index: &GraphIndex, t: &(SlotTerm, SlotTerm, SlotTerm)) -> u64 {
845    let dict = ctx.rete.dictionary();
846    match (
847        const_subject(&t.0, dict),
848        const_predicate(&t.1),
849        const_object(&t.2, dict),
850    ) {
851        (Some(s), Some(p), Some(o)) => routed_scan_bytes(index, (s, p, o)),
852        _ => 0,
853    }
854}
855
856/// [`pattern_scan_bytes`] for a pattern already in role-id space.
857pub(crate) fn routed_scan_bytes(index: &GraphIndex, pattern: Pattern) -> u64 {
858    let sections = index.tile_sections();
859    let mut best = u64::MAX;
860    // Only the permutations the FILE carries: an absent section is an empty
861    // `Vec<Tile>`, and summing its (zero) tile lengths would report a free scan
862    // for the one permutation that cannot be scanned at all.
863    for perm in index.perms().iter() {
864        let tiles = sections[perm.section_index()];
865        best = best.min(match perm.order_pattern(pattern)[0] {
866            Some(id) => tiles
867                .iter()
868                .filter(|tile| {
869                    let (lo, hi) = tile.leading_range();
870                    lo <= id && id <= hi
871                })
872                .map(Tile::encoded_len)
873                .sum(),
874            None => tiles.iter().map(Tile::encoded_len).sum(),
875        });
876    }
877    best
878}
879
880/// Estimated matches above which a join side counts as "fat": materializing
881/// (or, on a remote index, fetching) a million rows to build a hash/merge side
882/// is past the point where probing it per surviving row wins. Shared by the
883/// merge-seed asymmetry gate here and the force-probe gates in `sparql::eval`.
884///
885/// A ROW estimate ([`GraphIndex::estimate_matches`]: routed tiles × rows per
886/// tile), not a byte figure. Tiles are cut by their encoded size before
887/// compression, so the estimate does not move when only the compression does;
888/// the compressed tile bytes this gate used to read did — at zstd level 19,
889/// two OpenCitations queries crossed the old 2 MiB line and ran 3–7× slower
890/// with up to 6× the memory — and they differed between a resident open
891/// (decoded sizes) and a lazy one (compressed sizes) of the same file.
892pub(crate) const FAT_SCAN_ROWS: f64 = (1u64 << 20) as f64;
893
894/// Would a merge join of `ta` and `tb` materialize a fat side next to a
895/// pinpoint one? (One side above [`FAT_SCAN_ROWS`] and at least four times the
896/// other.) Then the hybrid loop — scan the small side, probe the fat one — is
897/// strictly better, and the merge seed is declined.
898fn merge_seed_lopsided(
899    ctx: &Ctx,
900    index: &GraphIndex,
901    ta: &(SlotTerm, SlotTerm, SlotTerm),
902    tb: &(SlotTerm, SlotTerm, SlotTerm),
903) -> bool {
904    let est = tile_estimates(ctx, index, &[*ta, *tb], &std::collections::HashSet::new());
905    let (lo, hi) = (est[0].min(est[1]), est[0].max(est[1]));
906    hi >= FAT_SCAN_ROWS && hi / 4.0 >= lo
907}
908
909/// Order pattern indices for a left-deep join: cheapest (smallest estimated
910/// cardinality) first, always preferring a pattern that shares a variable with
911/// the already-joined set so the join stays connected and intermediate relations
912/// stay small. Cardinality comes from [`pattern_estimates`]; with no summary it
913/// falls back to a most-constants-first heuristic. Pure reordering — `hash_join`
914/// is order-independent, so the result multiset is unchanged.
915fn selectivity_order(
916    ctx: &Ctx,
917    index: &GraphIndex,
918    lowered: &[(SlotTerm, SlotTerm, SlotTerm)],
919) -> Vec<usize> {
920    selectivity_order_seeded(ctx, index, lowered, &std::collections::HashSet::new())
921}
922
923/// [`selectivity_order`] with a set of slots already bound by an outer seed
924/// row: those variables count as already connected (and, in the no-summary
925/// fallback, as constants for selectivity).
926fn selectivity_order_seeded(
927    ctx: &Ctx,
928    index: &GraphIndex,
929    lowered: &[(SlotTerm, SlotTerm, SlotTerm)],
930    seed: &std::collections::HashSet<usize>,
931) -> Vec<usize> {
932    // Exact per-predicate statistics when the pyramid is resident; otherwise
933    // (the lazy path, and files built without a pyramid) the tile-directory
934    // estimate, which needs no fetch. That one also counts a class's own
935    // instances (`?s a :C` routes to the tiles of `:C`), so the
936    // class-enumeration rule below, a stand-in for exactly that missing
937    // number, applies only to the pyramid figures, whose `rdf:type`
938    // selectivity is an average over classes.
939    let (estimates, per_class) = match pattern_estimates(ctx, lowered, seed) {
940        Some(e) => (Some(e), false),
941        None => (Some(tile_estimates(ctx, index, lowered, seed)), true),
942    };
943    let consts = |t: &(SlotTerm, SlotTerm, SlotTerm)| {
944        [&t.0, &t.1, &t.2]
945            .into_iter()
946            .filter(|x| match x {
947                SlotTerm::Var(v) => seed.contains(v),
948                _ => true,
949            })
950            .count()
951    };
952    let vars = |t: &(SlotTerm, SlotTerm, SlotTerm)| -> Vec<usize> {
953        [&t.0, &t.1, &t.2]
954            .into_iter()
955            .filter_map(|x| match x {
956                SlotTerm::Var(v) => Some(*v),
957                _ => None,
958            })
959            .collect()
960    };
961    // Higher score = picked sooner. With summary stats that's the negated
962    // estimate (smaller cardinality wins); without, the constant count (old
963    // behaviour, byte-identical ordering for pyramid-less files).
964    let score = |i: usize| -> f64 {
965        match &estimates {
966            Some(e) => -e[i],
967            None => consts(&lowered[i]) as f64,
968        }
969    };
970    // A `?s rdf:type <Class>` with an as-yet-unbound subject is a **class
971    // enumeration**: it matches every instance of the class, so it is almost never
972    // the most selective place to *start* a join (a popular class has thousands of
973    // instances), even though it has two bound positions and so looks selective to
974    // the constant-count heuristic. Deprioritize it as a seed; once its subject is
975    // bound by another pattern it becomes a cheap per-row type *check* (the hybrid
976    // join probes it), so pushing it later costs nothing. Robust and stats-free.
977    let type_pid = ctx.rete.dictionary().predicate_id(crate::file::RDF_TYPE);
978    let is_class_enum = |i: usize, bound: &std::collections::HashSet<usize>| -> bool {
979        let t = &lowered[i];
980        !per_class
981            && type_pid.is_some_and(|tp| matches!(t.1, SlotTerm::Pred(p) if p == tp))
982            && matches!(t.2, SlotTerm::Node(_))
983            && matches!(t.0, SlotTerm::Var(v) if !bound.contains(&v))
984    };
985    let n = lowered.len();
986    let mut remaining: Vec<usize> = (0..n).collect();
987    let mut order: Vec<usize> = Vec::with_capacity(n);
988    let mut bound: std::collections::HashSet<usize> = seed.clone();
989    while !remaining.is_empty() {
990        // Pick the remaining pattern with the best (connected, not-a-class-enum,
991        // score) key; ties go to the lowest original index for a stable order.
992        let best = *remaining
993            .iter()
994            .max_by(|&&a, &&b| {
995                let connected = |i: usize| vars(&lowered[i]).iter().any(|v| bound.contains(v));
996                connected(a)
997                    .cmp(&connected(b))
998                    .then_with(|| is_class_enum(b, &bound).cmp(&is_class_enum(a, &bound)))
999                    .then_with(|| {
1000                        score(a)
1001                            .partial_cmp(&score(b))
1002                            .unwrap_or(std::cmp::Ordering::Equal)
1003                    })
1004                    .then_with(|| b.cmp(&a))
1005            })
1006            .unwrap();
1007        for v in vars(&lowered[best]) {
1008            bound.insert(v);
1009        }
1010        order.push(best);
1011        remaining.retain(|&i| i != best);
1012    }
1013    order
1014}
1015
1016/// Does the BGP have at least one solution against `index`? A single pattern
1017/// with all-distinct variables streams the index and stops at the first match
1018/// (no materialization); anything else falls back to the full evaluator and
1019/// tests non-emptiness (still benefiting from the lazy per-pattern scan). Used
1020/// by `ASK`.
1021pub(crate) fn bgp_exists(ctx: &Ctx, index: &GraphIndex, patterns: &[TriplePattern]) -> bool {
1022    let dict = ctx.rete.dictionary();
1023    let Some(lowered) = lower(patterns, ctx) else {
1024        return false;
1025    };
1026    if let [t] = lowered.as_slice() {
1027        // The fast path can't enforce a variable repeated across positions
1028        // (e.g. `?x p ?x`) — that needs the row builder — so only take it when
1029        // the pattern's variables are all distinct.
1030        let names: Vec<usize> = [&t.0, &t.1, &t.2]
1031            .into_iter()
1032            .filter_map(|x| match x {
1033                SlotTerm::Var(v) => Some(*v),
1034                _ => None,
1035            })
1036            .collect();
1037        let distinct = names
1038            .iter()
1039            .enumerate()
1040            .all(|(i, v)| !names[i + 1..].contains(v));
1041        if distinct {
1042            return match (
1043                const_subject(&t.0, dict),
1044                const_predicate(&t.1),
1045                const_object(&t.2, dict),
1046            ) {
1047                (Some(s), Some(p), Some(o)) => index.scan_iter((s, p, o)).next().is_some(),
1048                _ => false,
1049            };
1050        }
1051    }
1052    !eval_bgp_rows(ctx, index, patterns).is_empty()
1053}
1054
1055/// A lazy left-deep BGP join that yields solution rows one at a time, so a
1056/// consumer under `LIMIT`/`OFFSET` (or `.next()` for ASK) can stop early —
1057/// stopping also stops the underlying index scan.
1058///
1059/// The all-but-last patterns are joined eagerly into a `prefix` **hash table**
1060/// (they are the most selective, so this is the small side); the *last* (least
1061/// selective) pattern is then **streamed** from the index cursor, probing the
1062/// prefix — the big scan is never materialized, and a `LIMIT` above stops it
1063/// after a handful of triples. Yields the same solution multiset as
1064/// [`eval_bgp_rows`] (the join is order-independent), just incrementally.
1065pub(crate) struct BgpSolutions<'q> {
1066    scan: Option<Box<dyn Iterator<Item = Row> + 'q>>,
1067    prefix: Vec<Row>,
1068    /// Prefix row indices keyed by the shared-slot values.
1069    buckets: HashMap<Vec<Val>, Vec<usize>>,
1070    shared: Vec<usize>,
1071    cartesian: bool,
1072    /// The prefix is the all-unbound seed row (single-pattern BGP): scanned
1073    /// rows are already complete solutions — pass them through unmerged.
1074    seed_only: bool,
1075    cur_scan: Option<Row>,
1076    /// Reused candidate buffer (avoids a Vec allocation per scanned row).
1077    matches: Vec<usize>,
1078    mi: usize,
1079}
1080
1081impl<'q> BgpSolutions<'q> {
1082    /// Yield `rows` as complete solutions (no prefix merge).
1083    fn passthrough(rows: Box<dyn Iterator<Item = Row> + 'q>) -> Self {
1084        BgpSolutions {
1085            scan: Some(rows),
1086            prefix: Vec::new(),
1087            buckets: HashMap::new(),
1088            shared: Vec::new(),
1089            cartesian: false,
1090            seed_only: true,
1091            cur_scan: None,
1092            matches: Vec::new(),
1093            mi: 0,
1094        }
1095    }
1096
1097    /// The fully streaming join, for a BGP whose most selective pattern is
1098    /// itself large ([`STREAM_SEED_ROWS`]). Nothing proportional to it is ever
1099    /// held: the seed pattern streams from its index cursor (or, when the next
1100    /// pattern is just as large and shares its subject variable, both stream
1101    /// through a merge join), and each later pattern joins per row — through
1102    /// a hash table when the pattern is small ([`HASH_BUILD_ROWS`]), by an
1103    /// index probe otherwise. Same solution multiset as the materializing
1104    /// path; only the row order differs.
1105    fn streaming(
1106        ctx: &'q Ctx<'q>,
1107        index: &'q GraphIndex,
1108        lowered: &[(SlotTerm, SlotTerm, SlotTerm)],
1109        order: &[usize],
1110        est: &[f64],
1111    ) -> Self {
1112        let (mut stream, mut bound, skip): (Box<dyn Iterator<Item = Row> + 'q>, Vec<usize>, usize) =
1113            match (est[order[1]] > limits().stream_seed)
1114                .then(|| merge_stream(ctx, index, lowered[order[0]], lowered[order[1]]))
1115                .flatten()
1116            {
1117                Some((m, slots)) => (m, slots, 2),
1118                None => {
1119                    let t = lowered[order[0]];
1120                    let Some(scan) = scan_rows(ctx, index, t) else {
1121                        return Self::empty();
1122                    };
1123                    (Box::new(scan), pattern_slots(&t), 1)
1124                }
1125            };
1126        for &i in &order[skip..] {
1127            let t = lowered[i];
1128            let slots = pattern_slots(&t);
1129            let shared: Vec<usize> = slots
1130                .iter()
1131                .copied()
1132                .filter(|s| bound.contains(s))
1133                .collect();
1134            if !shared.is_empty() && est[i] > limits().hash_build {
1135                stream = Box::new(stream.flat_map(move |base| probe_rows(ctx, index, t, base)));
1136            } else {
1137                // Small (or unconnected) pattern: scan it once into a table
1138                // keyed on the shared slots and join every streamed row to it.
1139                let Some((rel, _)) = pattern_rows(ctx, index, &t) else {
1140                    return Self::empty();
1141                };
1142                let mut table: HashMap<Vec<Val>, Vec<Row>> = HashMap::new();
1143                for r in rel {
1144                    let key: Vec<Val> = shared.iter().map(|&s| r[s].clone().unwrap()).collect();
1145                    table.entry(key).or_default().push(r);
1146                }
1147                let fill = slots.clone();
1148                stream = Box::new(stream.flat_map(move |base| {
1149                    let key: Vec<Val> = shared.iter().map(|&s| base[s].clone().unwrap()).collect();
1150                    let out: Vec<Row> = table
1151                        .get(&key)
1152                        .map(|rs| {
1153                            rs.iter()
1154                                .map(|r| {
1155                                    let mut row = base.clone();
1156                                    for &s in &fill {
1157                                        row[s] = r[s].clone();
1158                                    }
1159                                    row
1160                                })
1161                                .collect()
1162                        })
1163                        .unwrap_or_default();
1164                    out.into_iter()
1165                }));
1166            }
1167            for s in slots {
1168                if !bound.contains(&s) {
1169                    bound.push(s);
1170                }
1171            }
1172        }
1173        Self::passthrough(stream)
1174    }
1175
1176    /// An iterator that yields nothing (an unsatisfiable BGP).
1177    fn empty() -> Self {
1178        BgpSolutions {
1179            scan: None,
1180            prefix: Vec::new(),
1181            buckets: HashMap::new(),
1182            shared: Vec::new(),
1183            cartesian: false,
1184            seed_only: false,
1185            cur_scan: None,
1186            matches: Vec::new(),
1187            mi: 0,
1188        }
1189    }
1190
1191    pub(crate) fn new(ctx: &'q Ctx<'q>, index: &'q GraphIndex, patterns: &[TriplePattern]) -> Self {
1192        // An empty BGP has exactly one (empty) solution.
1193        if patterns.is_empty() {
1194            return BgpSolutions {
1195                scan: Some(Box::new(std::iter::once(ctx.slots.empty_row()))),
1196                prefix: vec![ctx.slots.empty_row()],
1197                buckets: HashMap::new(),
1198                shared: Vec::new(),
1199                cartesian: true,
1200                seed_only: true,
1201                cur_scan: None,
1202                matches: Vec::new(),
1203                mi: 0,
1204            };
1205        }
1206        // Lower all patterns; an unknown constant term ⇒ no solutions.
1207        let Some(lowered) = lower(patterns, ctx) else {
1208            return Self::empty();
1209        };
1210        // Join all but the *last* (least selective) pattern eagerly into the
1211        // prefix; a single pattern leaves the seed row as the prefix.
1212        let order = selectivity_order(ctx, index, &lowered);
1213        let scan_est = tile_estimates(ctx, index, &lowered, &std::collections::HashSet::new());
1214        // Even the most selective pattern is big: materializing it as the
1215        // prefix would hold every one of its rows. Stream the whole join.
1216        if order.len() >= 2 && scan_est[order[0]] > limits().stream_seed {
1217            return Self::streaming(ctx, index, &lowered, &order, &scan_est);
1218        }
1219        let (&last_i, prefix_is) = order.split_last().unwrap();
1220        let prefix_pats: Vec<TriplePattern> =
1221            prefix_is.iter().map(|&i| patterns[i].clone()).collect();
1222        let prefix = eval_bgp_rows(ctx, index, &prefix_pats);
1223        if prefix.is_empty() {
1224            return Self::empty();
1225        }
1226
1227        // Hash-join key = slots shared by the prefix and the last pattern (BGP
1228        // rows bind every slot of their pattern set, so the prefix's bound set
1229        // can be read off its first row).
1230        let shared: Vec<usize> = pattern_slots(&lowered[last_i])
1231            .into_iter()
1232            .filter(|&s| prefix[0][s].is_some())
1233            .collect();
1234
1235        // A few prefix rows against a big last pattern: look each one up
1236        // instead of scanning the pattern. `?r :relationSubject <doi> . ?r ?p
1237        // ?o` has four prefix rows and a last pattern that is the whole graph.
1238        if !shared.is_empty() && probe_beats_scan(prefix.len(), scan_est[last_i]) {
1239            let t = lowered[last_i];
1240            if index.is_remote() {
1241                prefetch_rows_for(ctx, index, t, &prefix);
1242            }
1243            let stream = prefix
1244                .into_iter()
1245                .flat_map(move |base| probe_rows(ctx, index, t, base));
1246            return Self::passthrough(Box::new(stream));
1247        }
1248
1249        // Stream the last pattern with the lazy index cursor.
1250        let Some(scan) = scan_rows(ctx, index, lowered[last_i]) else {
1251            return Self::empty();
1252        };
1253        let cartesian = shared.is_empty();
1254        // A single-pattern BGP joins against the all-unbound seed: scanned rows
1255        // are already complete solutions.
1256        let seed_only = prefix.len() == 1 && prefix[0].iter().all(Option::is_none);
1257        let mut buckets: HashMap<Vec<Val>, Vec<usize>> = HashMap::new();
1258        if !cartesian {
1259            for (i, r) in prefix.iter().enumerate() {
1260                let key: Vec<Val> = shared.iter().map(|&s| r[s].clone().unwrap()).collect();
1261                buckets.entry(key).or_default().push(i);
1262            }
1263        }
1264        BgpSolutions {
1265            scan: Some(Box::new(scan)),
1266            prefix,
1267            buckets,
1268            shared,
1269            cartesian,
1270            seed_only,
1271            cur_scan: None,
1272            matches: Vec::new(),
1273            mi: 0,
1274        }
1275    }
1276}
1277
1278impl Iterator for BgpSolutions<'_> {
1279    type Item = Row;
1280
1281    fn next(&mut self) -> Option<Row> {
1282        // Single-pattern fast path: pass scanned rows through unmerged.
1283        if self.seed_only {
1284            return self.scan.as_mut()?.next();
1285        }
1286        loop {
1287            // Emit the next prefix match for the current scanned row.
1288            if self.mi < self.matches.len() {
1289                let pi = self.matches[self.mi];
1290                self.mi += 1;
1291                let mut merged = self.prefix[pi].clone();
1292                for (slot, v) in self.cur_scan.as_ref().unwrap().iter().enumerate() {
1293                    if v.is_some() {
1294                        merged[slot] = v.clone();
1295                    }
1296                }
1297                return Some(merged);
1298            }
1299            // Pull the next row from the index scan and gather its matches
1300            // (into the reused buffer — no allocation per scanned row).
1301            let s = self.scan.as_mut()?.next()?;
1302            self.matches.clear();
1303            if self.cartesian {
1304                self.matches.extend(0..self.prefix.len());
1305            } else {
1306                let key: Vec<Val> = self.shared.iter().map(|&i| s[i].clone().unwrap()).collect();
1307                if let Some(c) = self.buckets.get(&key) {
1308                    self.matches.extend_from_slice(c);
1309                }
1310            }
1311            self.mi = 0;
1312            self.cur_scan = Some(s);
1313        }
1314    }
1315}
1316
1317/// Batch-fault the tiles that probing `t` from each of `rows` will route to
1318/// (remote readers only; see [`GraphIndex::prefetch_probe_tiles`]).
1319fn prefetch_rows_for(
1320    ctx: &Ctx,
1321    index: &GraphIndex,
1322    t: (SlotTerm, SlotTerm, SlotTerm),
1323    rows: &[Row],
1324) {
1325    let pats: Vec<Pattern> = rows
1326        .iter()
1327        .filter_map(|base| {
1328            match (
1329                probe_subject(ctx, &t.0, base),
1330                probe_predicate(ctx, &t.1, base),
1331                probe_object(ctx, &t.2, base),
1332            ) {
1333                (Some(s), Some(p), Some(o)) => Some((s, p, o)),
1334                _ => None,
1335            }
1336        })
1337        .collect();
1338    index.prefetch_probe_tiles(&pats);
1339}
1340
1341/// A **streaming** merge join of two patterns that share exactly one variable,
1342/// in the subject position of both: each side streams from a permutation
1343/// sorted on subject ([`GraphIndex::scan_iter_sorted_on`]) and only the
1344/// current subject's rows are held. Restricted to the subject column because a
1345/// subject's rows are its out-edges, a small run; an object run (every
1346/// instance of a class) can be the whole pattern. `None` when the shape does
1347/// not qualify; the caller then streams the first pattern alone.
1348#[allow(clippy::type_complexity)]
1349fn merge_stream<'q>(
1350    ctx: &'q Ctx<'q>,
1351    index: &'q GraphIndex,
1352    ta: (SlotTerm, SlotTerm, SlotTerm),
1353    tb: (SlotTerm, SlotTerm, SlotTerm),
1354) -> Option<(Box<dyn Iterator<Item = Row> + 'q>, Vec<usize>)> {
1355    let sa = pattern_slots(&ta);
1356    let sb = pattern_slots(&tb);
1357    let shared: Vec<usize> = sa.iter().copied().filter(|s| sb.contains(s)).collect();
1358    let [v] = shared[..] else {
1359        return None;
1360    };
1361    if !matches!(ta.0, SlotTerm::Var(i) if i == v) || !matches!(tb.0, SlotTerm::Var(i) if i == v) {
1362        return None;
1363    }
1364    let dict = ctx.rete.dictionary();
1365    let lower = |t: &(SlotTerm, SlotTerm, SlotTerm)| -> Option<Pattern> {
1366        Some((
1367            const_subject(&t.0, dict)?,
1368            const_predicate(&t.1)?,
1369            const_object(&t.2, dict)?,
1370        ))
1371    };
1372    let side = |t: (SlotTerm, SlotTerm, SlotTerm)| -> Option<KeyedRows<'q>> {
1373        let it = index.scan_iter_sorted_on(lower(&t)?, 0)?;
1374        let keyed: Box<dyn Iterator<Item = (u32, Row)> + 'q> =
1375            Box::new(it.filter_map(move |tri| triple_row(ctx, &t, tri).map(|r| (tri.0, r))));
1376        Some(keyed.peekable())
1377    };
1378    let a = side(ta)?;
1379    let b = side(tb)?;
1380    let mut slots = sa;
1381    for s in sb {
1382        if !slots.contains(&s) {
1383            slots.push(s);
1384        }
1385    }
1386    Some((
1387        Box::new(MergeStream {
1388            a,
1389            b,
1390            ra: Vec::new(),
1391            rb: Vec::new(),
1392            i: 0,
1393            j: 0,
1394        }),
1395        slots,
1396    ))
1397}
1398
1399type KeyedRows<'q> = std::iter::Peekable<Box<dyn Iterator<Item = (u32, Row)> + 'q>>;
1400
1401/// The two-pointer merge behind [`merge_stream`]: `ra`/`rb` hold the current
1402/// equal-key runs and `(i, j)` walks their cross product lazily.
1403struct MergeStream<'q> {
1404    a: KeyedRows<'q>,
1405    b: KeyedRows<'q>,
1406    ra: Vec<Row>,
1407    rb: Vec<Row>,
1408    i: usize,
1409    j: usize,
1410}
1411
1412impl Iterator for MergeStream<'_> {
1413    type Item = Row;
1414
1415    fn next(&mut self) -> Option<Row> {
1416        loop {
1417            while self.i < self.ra.len() {
1418                if self.j < self.rb.len() {
1419                    let j = self.j;
1420                    self.j += 1;
1421                    if let Some(m) = crate::row::merge_rows(&self.ra[self.i], &self.rb[j]) {
1422                        return Some(m);
1423                    }
1424                } else {
1425                    self.i += 1;
1426                    self.j = 0;
1427                }
1428            }
1429            let ka = self.a.peek()?.0;
1430            let kb = self.b.peek()?.0;
1431            match ka.cmp(&kb) {
1432                std::cmp::Ordering::Less => {
1433                    self.a.next();
1434                }
1435                std::cmp::Ordering::Greater => {
1436                    self.b.next();
1437                }
1438                std::cmp::Ordering::Equal => {
1439                    self.ra.clear();
1440                    self.rb.clear();
1441                    while let Some((_, r)) = self.a.next_if(|(k, _)| *k == ka) {
1442                        self.ra.push(r);
1443                    }
1444                    while let Some((_, r)) = self.b.next_if(|(k, _)| *k == ka) {
1445                        self.rb.push(r);
1446                    }
1447                    self.i = 0;
1448                    self.j = 0;
1449                }
1450            }
1451        }
1452    }
1453}
1454
1455// --- index-nested-loop probing ------------------------------------------------
1456//
1457// Under a small, known demand (LIMIT/ASK — see `Ctx::limit_hint`), scanning
1458// every pattern once to hash-join is mostly wasted work: the consumer wants a
1459// handful of rows. The probe path instead streams the seed/first pattern and,
1460// per row, *probes* each next pattern through the index with the row's bound
1461// values substituted as scan constants — so producing k solutions touches
1462// O(k · patterns) index groups instead of every pattern's full extent. Same
1463// solution multiset as the hash join (joins are order-independent); only the
1464// evaluation order differs.
1465
1466/// Index-scan constant for a subject position given a partially-bound row.
1467/// Outer `None` = unsatisfiable for this row; inner `None` = wildcard.
1468fn probe_subject(ctx: &Ctx, t: &SlotTerm, base: &Row) -> Option<Option<u32>> {
1469    let dict = ctx.rete.dictionary();
1470    match t {
1471        SlotTerm::Node(n) => dict.node_as_subject_id(*n).map(Some),
1472        SlotTerm::Pred(_) => None,
1473        SlotTerm::Var(i) => match &base[*i] {
1474            None => Some(None),
1475            Some(Val::Id(v)) if *v >= 0 => dict.node_as_subject_id(*v as u32).map(Some),
1476            // A predicate-tagged or computed value can never be a subject.
1477            Some(_) => None,
1478        },
1479    }
1480}
1481
1482/// Index-scan constant for an object position (see [`probe_subject`]).
1483fn probe_object(ctx: &Ctx, t: &SlotTerm, base: &Row) -> Option<Option<u32>> {
1484    let dict = ctx.rete.dictionary();
1485    match t {
1486        SlotTerm::Node(n) => dict.node_as_object_id(*n).map(Some),
1487        SlotTerm::Pred(_) => None,
1488        SlotTerm::Var(i) => match &base[*i] {
1489            None => Some(None),
1490            Some(Val::Id(v)) if *v >= 0 => dict.node_as_object_id(*v as u32).map(Some),
1491            Some(_) => None,
1492        },
1493    }
1494}
1495
1496/// Index-scan constant for a predicate position (see [`probe_subject`]).
1497fn probe_predicate(ctx: &Ctx, t: &SlotTerm, base: &Row) -> Option<Option<u32>> {
1498    let dict = ctx.rete.dictionary();
1499    match t {
1500        SlotTerm::Pred(p) => Some(Some(*p)),
1501        SlotTerm::Node(_) => None,
1502        SlotTerm::Var(i) => match &base[*i] {
1503            None => Some(None),
1504            Some(Val::Id(v)) if *v < 0 => Some(Some((-v - 1) as u32)),
1505            // Canonicalized to a node id — the term may still be a predicate.
1506            Some(Val::Id(v)) => ctx
1507                .resolver
1508                .term(*v)
1509                .and_then(|t| dict.predicate_id(&t))
1510                .map(Some),
1511            Some(Val::Str(_)) => None,
1512        },
1513    }
1514}
1515
1516/// Probe one pattern with a partially-bound row: every bound variable becomes
1517/// an index-scan constant, and each matching triple extends a clone of the row
1518/// (repeated unbound variables stay consistent).
1519fn probe_rows<'q>(
1520    ctx: &'q Ctx<'q>,
1521    index: &'q GraphIndex,
1522    t: (SlotTerm, SlotTerm, SlotTerm),
1523    base: Row,
1524) -> Box<dyn Iterator<Item = Row> + 'q> {
1525    let (Some(sid), Some(pid), Some(oid)) = (
1526        probe_subject(ctx, &t.0, &base),
1527        probe_predicate(ctx, &t.1, &base),
1528        probe_object(ctx, &t.2, &base),
1529    ) else {
1530        return Box::new(std::iter::empty());
1531    };
1532    let dict = ctx.rete.dictionary();
1533    Box::new(
1534        index
1535            .scan_iter((sid, pid, oid))
1536            .filter_map(move |(s_id, p_id, o_id)| {
1537                let s_val = dict.subject_node(s_id) as i64;
1538                let p_val = ctx.resolver.canon_id(pred_tag(p_id));
1539                let o_val = dict.object_node(o_id) as i64;
1540                let mut row = base.clone();
1541                for (term, val) in [(&t.0, s_val), (&t.1, p_val), (&t.2, o_val)] {
1542                    if let SlotTerm::Var(i) = term {
1543                        match row[*i] {
1544                            Some(Val::Id(existing)) if existing != val => return None,
1545                            Some(Val::Id(_)) => {}
1546                            Some(Val::Str(_)) => return None,
1547                            None => row[*i] = Some(Val::Id(val)),
1548                        }
1549                    }
1550                }
1551                Some(row)
1552            }),
1553    )
1554}
1555
1556/// A lowered, probe-ordered BGP, reusable across many seed rows.
1557pub(crate) struct ProbePlan {
1558    pats: Vec<(SlotTerm, SlotTerm, SlotTerm)>,
1559}
1560
1561impl ProbePlan {
1562    /// Lower and order `patterns` for probing from rows that bind (at least)
1563    /// the slots in `seed_mask`. `None` = a constant term is unknown, making
1564    /// the BGP unsatisfiable for every seed.
1565    pub(crate) fn new(
1566        ctx: &Ctx,
1567        index: &GraphIndex,
1568        patterns: &[TriplePattern],
1569        seed_mask: &[bool],
1570    ) -> Option<Self> {
1571        let lowered = lower(patterns, ctx)?;
1572        let seed: std::collections::HashSet<usize> = seed_mask
1573            .iter()
1574            .enumerate()
1575            .filter_map(|(i, b)| b.then_some(i))
1576            .collect();
1577        let order = selectivity_order_seeded(ctx, index, &lowered, &seed);
1578        Some(ProbePlan {
1579            pats: order.into_iter().map(|i| lowered[i]).collect(),
1580        })
1581    }
1582}
1583
1584/// Depth-first index-nested-loop join over a [`ProbePlan`], starting from a
1585/// seed row. Fully lazy: pulling k rows probes O(k · patterns) index groups.
1586pub(crate) struct ProbeJoin<'q> {
1587    ctx: &'q Ctx<'q>,
1588    index: &'q GraphIndex,
1589    pats: Vec<(SlotTerm, SlotTerm, SlotTerm)>,
1590    stack: Vec<Box<dyn Iterator<Item = Row> + 'q>>,
1591}
1592
1593impl<'q> ProbeJoin<'q> {
1594    /// Probe a whole BGP from scratch (the seed is the all-unbound row).
1595    /// `None` = unsatisfiable.
1596    pub(crate) fn new(
1597        ctx: &'q Ctx<'q>,
1598        index: &'q GraphIndex,
1599        patterns: &[TriplePattern],
1600    ) -> Option<Self> {
1601        let plan = ProbePlan::new(ctx, index, patterns, &vec![false; ctx.slots.len()])?;
1602        Some(Self::from_plan(ctx, index, &plan, ctx.slots.empty_row()))
1603    }
1604
1605    /// Probe a pre-lowered plan from one seed row.
1606    pub(crate) fn from_plan(
1607        ctx: &'q Ctx<'q>,
1608        index: &'q GraphIndex,
1609        plan: &ProbePlan,
1610        seed: Row,
1611    ) -> Self {
1612        let pats = plan.pats.clone();
1613        let first = probe_rows(ctx, index, pats[0], seed);
1614        ProbeJoin {
1615            ctx,
1616            index,
1617            pats,
1618            stack: vec![first],
1619        }
1620    }
1621}
1622
1623impl Iterator for ProbeJoin<'_> {
1624    type Item = Row;
1625
1626    fn next(&mut self) -> Option<Row> {
1627        loop {
1628            let depth = self.stack.len();
1629            match self.stack.last_mut()?.next() {
1630                Some(row) => {
1631                    if depth == self.pats.len() {
1632                        return Some(row);
1633                    }
1634                    let it = probe_rows(self.ctx, self.index, self.pats[depth], row);
1635                    self.stack.push(it);
1636                }
1637                None => {
1638                    self.stack.pop();
1639                    if self.stack.is_empty() {
1640                        return None;
1641                    }
1642                }
1643            }
1644        }
1645    }
1646}
1647
1648/// Hash-join two BGP relations on the slots they share. Every left row binds
1649/// exactly the slots accumulated so far and every right row binds the current
1650/// pattern's slots, so the shared set is uniform across rows.
1651fn hash_join(
1652    left: Vec<Row>,
1653    left_bound: &[usize],
1654    right: Vec<Row>,
1655    right_slots: &[usize],
1656) -> Vec<Row> {
1657    // Fast path: the all-unbound seed row joins to `right` unchanged.
1658    if left.len() == 1 && left_bound.is_empty() {
1659        return right;
1660    }
1661    if left.is_empty() || right.is_empty() {
1662        return Vec::new();
1663    }
1664    let shared: Vec<usize> = right_slots
1665        .iter()
1666        .copied()
1667        .filter(|s| left_bound.contains(s))
1668        .collect();
1669    let fill = |l: &Row, r: &Row| -> Row {
1670        let mut out = l.clone();
1671        for &s in right_slots {
1672            out[s] = r[s].clone();
1673        }
1674        out
1675    };
1676    if shared.is_empty() {
1677        // No shared variable: Cartesian product. In a connected BGP this only
1678        // arises for a fully-ground pattern (deduped index ⇒ one matching row),
1679        // so it acts as an existence filter rather than multiplying rows.
1680        let mut out = Vec::with_capacity(left.len() * right.len());
1681        for l in &left {
1682            for r in &right {
1683                out.push(fill(l, r));
1684            }
1685        }
1686        return out;
1687    }
1688    let key_of = |b: &Row| -> Vec<Val> { shared.iter().map(|&s| b[s].clone().unwrap()).collect() };
1689    // A hash join is symmetric on the shared key, so build the **smaller** side's
1690    // hash table and probe with the larger — same result set, fewer inserts and a
1691    // smaller table. (The old code always built `right`; after cardinality-based
1692    // ordering the accumulating `left` is usually the small selective side, so
1693    // building it instead is the common win.) Whichever side is built, the output
1694    // row carries every bound slot of both.
1695    let mut out = Vec::new();
1696    if right.len() <= left.len() {
1697        let mut buckets: HashMap<Vec<Val>, Vec<Row>> = HashMap::new();
1698        for r in right {
1699            buckets.entry(key_of(&r)).or_default().push(r);
1700        }
1701        for l in &left {
1702            if let Some(rs) = buckets.get(&key_of(l)) {
1703                for r in rs {
1704                    out.push(fill(l, r));
1705                }
1706            }
1707        }
1708    } else {
1709        // Build the left side; fill the left's own slots into each probed right.
1710        let mut buckets: HashMap<Vec<Val>, Vec<Row>> = HashMap::new();
1711        for l in left {
1712            buckets.entry(key_of(&l)).or_default().push(l);
1713        }
1714        for r in &right {
1715            if let Some(ls) = buckets.get(&key_of(r)) {
1716                for l in ls {
1717                    let mut row = r.clone();
1718                    for &s in left_bound {
1719                        if let Some(v) = &l[s] {
1720                            row[s] = Some(v.clone());
1721                        }
1722                    }
1723                    out.push(row);
1724                }
1725            }
1726        }
1727    }
1728    out
1729}
1730
1731// Constant-only index constraints. A variable scans as a wildcard (`Some(None)`)
1732// — it is resolved later by the hash join, not pushed into the scan. The outer
1733// `None` means "unsatisfiable" (a constant in an impossible role, or unknown to
1734// the dictionary), which empties the whole BGP. Inner `None` = "wildcard".
1735fn const_subject(t: &SlotTerm, d: &crate::Dictionary) -> Option<Option<u32>> {
1736    match t {
1737        SlotTerm::Node(n) => d.node_as_subject_id(*n).map(Some),
1738        SlotTerm::Pred(_) => None, // a predicate term can't be a subject
1739        SlotTerm::Var(_) => Some(None),
1740    }
1741}
1742
1743fn const_object(t: &SlotTerm, d: &crate::Dictionary) -> Option<Option<u32>> {
1744    match t {
1745        SlotTerm::Node(n) => d.node_as_object_id(*n).map(Some),
1746        SlotTerm::Pred(_) => None,
1747        SlotTerm::Var(_) => Some(None),
1748    }
1749}
1750
1751fn const_predicate(t: &SlotTerm) -> Option<Option<u32>> {
1752    match t {
1753        SlotTerm::Pred(p) => Some(Some(*p)),
1754        SlotTerm::Node(_) => None, // a node term can't be a predicate
1755        SlotTerm::Var(_) => Some(None),
1756    }
1757}
1758
1759#[cfg(test)]
1760mod tests {
1761    use super::*;
1762    use crate::dictionary::DictionaryBuilder;
1763    use crate::file::write_file;
1764    use crate::index::GraphIndexBuilder;
1765
1766    fn rete_from(triples: &[(&str, &str, &str)]) -> Vec<u8> {
1767        let mut db = DictionaryBuilder::new();
1768        for (s, p, o) in triples {
1769            db.observe(s, p, o);
1770        }
1771        let dict = db.build();
1772        let mut ib = GraphIndexBuilder::new();
1773        for (s, p, o) in triples {
1774            ib.push(dict.encode(s, p, o).unwrap());
1775        }
1776        write_file(&dict, &ib.build(), false, &[], 0)
1777    }
1778
1779    fn pat(s: &str, p: &str, o: &str) -> TriplePattern {
1780        TriplePattern {
1781            s: PatternTerm::parse(s),
1782            p: PatternTerm::parse(p),
1783            o: PatternTerm::parse(o),
1784        }
1785    }
1786
1787    /// The scan-bytes figure (`rete cost`'s preview, and through 0.3.7 the
1788    /// merge-join and fat-side gates) routes a bound term by its id IN ITS
1789    /// ROLE. It used to route the unified node id,
1790    /// which for an object-only term (any literal) is shifted past the whole
1791    /// subject-only block: `?s :value "lit"` found no OPS tile at all, cost
1792    /// "0 bytes", and so let a merge join materialize its partner in full —
1793    /// on OpenCitations, every DOI identifier of a 2.9 B-triple file, behind
1794    /// `?id :hasLiteralValue "10.…" ; :usesIdentifierScheme :doi`.
1795    #[test]
1796    fn scan_bytes_route_a_literal_by_its_object_id() {
1797        let value = "<http://ex/value>";
1798        let owned: Vec<(String, String)> = (0..400)
1799            .map(|i| (format!("<http://ex/s{i:03}>"), format!("\"lit {i:03}\"")))
1800            .collect();
1801        let mut db = DictionaryBuilder::new();
1802        for (s, o) in &owned {
1803            db.observe(s, value, o);
1804        }
1805        let dict = db.build();
1806        let mut ib = GraphIndexBuilder::new().with_tile_budget(256);
1807        for (s, o) in &owned {
1808            ib.push(dict.encode(s, value, o).unwrap());
1809        }
1810        let rete = Rete::open(&write_file(&dict, &ib.build(), false, &[], 0)).unwrap();
1811        let index = rete.default_index();
1812        // The whole predicate, the most a bound object could ever cost.
1813        let whole = routed_scan_bytes(index, (None, rete.dictionary().predicate_id(value), None));
1814        for (_, lit) in owned.iter().step_by(57) {
1815            let q = [pat("?s", value, lit)];
1816            let mut slots = Slots::new();
1817            collect_pattern_slots(&q, &mut slots);
1818            let ctx = Ctx::new(&rete, slots);
1819            let got = bgp_min_scan_bytes(&ctx, index, &q).unwrap();
1820            assert!(got > 0, "{lit} routed to no tile");
1821            assert!(
1822                got < whole,
1823                "{lit} routed to the whole predicate ({got} of {whole})"
1824            );
1825        }
1826    }
1827
1828    /// The planner's size gates read ROW estimates, which do not depend on how
1829    /// the tiles are compressed. The same file opened resident (tile sizes are
1830    /// the decoded images) and lazily (tile sizes are the compressed frames)
1831    /// reports different bytes but must take the same decisions — the byte
1832    /// gates this replaced flipped plans when only the zstd level changed.
1833    #[test]
1834    fn plan_gates_do_not_move_with_the_compression() {
1835        let mut owned: Vec<(String, String, String)> = Vec::new();
1836        for i in 0..3000 {
1837            let s = format!("<http://ex/s{i}>");
1838            owned.push((
1839                s.clone(),
1840                "<http://ex/kind>".into(),
1841                format!("<http://ex/K{}>", i % 7),
1842            ));
1843            owned.push((s.clone(), "<http://ex/value>".into(), format!("\"v {i}\"")));
1844            if i % 50 == 0 {
1845                owned.push((s, "<http://ex/rare>".into(), "<http://ex/R>".into()));
1846            }
1847        }
1848        let mut db = DictionaryBuilder::new();
1849        for (s, p, o) in &owned {
1850            db.observe(s, p, o);
1851        }
1852        let dict = db.build();
1853        let mut ib = GraphIndexBuilder::new().with_tile_budget(1024);
1854        for (s, p, o) in &owned {
1855            ib.push(dict.encode(s, p, o).unwrap());
1856        }
1857        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
1858        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
1859        let resident = Rete::open(&bytes).unwrap();
1860        let lazy = Rete::open_ranged_lazy(crate::reader::SliceReader::new(leaked)).unwrap();
1861        let shapes: Vec<Vec<TriplePattern>> = vec![
1862            vec![
1863                pat("?s", "<http://ex/rare>", "<http://ex/R>"),
1864                pat("?s", "<http://ex/value>", "?v"),
1865            ],
1866            vec![
1867                pat("?s", "<http://ex/kind>", "<http://ex/K3>"),
1868                pat("?s", "<http://ex/value>", "?v"),
1869            ],
1870            vec![
1871                pat("?s", "<http://ex/kind>", "?k"),
1872                pat("?s", "<http://ex/value>", "?v"),
1873            ],
1874            vec![
1875                pat("?s", "<http://ex/value>", "\"v 7\""),
1876                pat("?s", "<http://ex/kind>", "?k"),
1877            ],
1878        ];
1879        let decisions = |rete: &Rete| -> Vec<(Vec<u64>, bool, Option<u64>)> {
1880            shapes
1881                .iter()
1882                .map(|q| {
1883                    let mut slots = Slots::new();
1884                    collect_pattern_slots(q, &mut slots);
1885                    let ctx = Ctx::new(rete, slots);
1886                    let index = rete.default_index();
1887                    let lowered = lower(q, &ctx).unwrap();
1888                    let est = tile_estimates(&ctx, index, &lowered, &Default::default());
1889                    let lopsided = merge_seed_lopsided(&ctx, index, &lowered[0], &lowered[1]);
1890                    let min = bgp_min_estimate(&ctx, index, q).map(|e| e as u64);
1891                    (est.iter().map(|e| *e as u64).collect(), lopsided, min)
1892                })
1893                .collect()
1894        };
1895        assert_eq!(decisions(&resident), decisions(&lazy));
1896        // ... while the byte figures the gates used to read do differ.
1897        #[cfg(feature = "compression")]
1898        {
1899            let bytes_of = |rete: &Rete| {
1900                let q = &shapes[2];
1901                let mut slots = Slots::new();
1902                collect_pattern_slots(q, &mut slots);
1903                let ctx = Ctx::new(rete, slots);
1904                bgp_min_scan_bytes(&ctx, rete.default_index(), q)
1905            };
1906            assert_ne!(bytes_of(&resident), bytes_of(&lazy));
1907        }
1908    }
1909
1910    /// Every join path the planner can pick — materialized prefix, probed last
1911    /// pattern, streamed seed with hashed or probed later patterns, streaming
1912    /// merge join — returns the same solution multiset. The thresholds are
1913    /// shrunk so a 400-triple graph takes each path.
1914    #[test]
1915    fn every_join_path_returns_the_same_solutions() {
1916        let mut owned: Vec<(String, String, String)> = Vec::new();
1917        let mut x: u64 = 7;
1918        let mut next = |m: u64| {
1919            x = x
1920                .wrapping_mul(6364136223846793005)
1921                .wrapping_add(1442695040888963407);
1922            (x >> 33) % m
1923        };
1924        for i in 0..60 {
1925            let s = format!("s{i}");
1926            owned.push((s.clone(), "a".into(), format!("C{}", next(3))));
1927            for p in 0..4 {
1928                for _ in 0..next(3) {
1929                    owned.push((s.clone(), format!("p{p}"), format!("s{}", next(60))));
1930                }
1931            }
1932        }
1933        let triples: Vec<(&str, &str, &str)> = owned
1934            .iter()
1935            .map(|(s, p, o)| (s.as_str(), p.as_str(), o.as_str()))
1936            .collect();
1937        let bytes = rete_from(&triples);
1938        let rete = Rete::open(&bytes).unwrap();
1939        let queries: Vec<Vec<TriplePattern>> = vec![
1940            vec![pat("?s", "p0", "?a"), pat("?s", "p1", "?b")],
1941            vec![pat("?s", "a", "C1"), pat("?s", "p0", "?a")],
1942            vec![pat("?s", "p0", "?a"), pat("?a", "p2", "?b")],
1943            vec![pat("s5", "p0", "?a"), pat("?a", "?p", "?o")],
1944            vec![pat("?s", "p0", "?x"), pat("?s", "p1", "?x")],
1945            vec![
1946                pat("?s", "p3", "?o"),
1947                pat("?o", "a", "C0"),
1948                pat("?s", "p1", "?z"),
1949            ],
1950            vec![
1951                pat("?s", "p0", "?a"),
1952                pat("?s", "p1", "?b"),
1953                pat("?b", "p2", "?c"),
1954            ],
1955        ];
1956        let run = |limits: Option<PlanLimits>| -> Vec<Vec<Vec<(String, String)>>> {
1957            TEST_LIMITS.with(|c| c.set(limits));
1958            let out = queries
1959                .iter()
1960                .map(|q| {
1961                    let mut slots = crate::row::Slots::new();
1962                    collect_pattern_slots(q, &mut slots);
1963                    let ctx = Ctx::new(&rete, slots);
1964                    let mut rows: Vec<Vec<(String, String)>> =
1965                        BgpSolutions::new(&ctx, rete.default_index(), q)
1966                            .map(|r| row_to_binding(&ctx, &r).into_iter().collect())
1967                            .collect();
1968                    rows.sort();
1969                    rows
1970                })
1971                .collect();
1972            TEST_LIMITS.with(|c| c.set(None));
1973            out
1974        };
1975        let reference = run(None);
1976        assert!(reference.iter().all(|r| !r.is_empty()), "{reference:?}");
1977        for (name, l) in [
1978            (
1979                "probe everything",
1980                PlanLimits {
1981                    probe_floor: 0.0,
1982                    stream_seed: f64::MAX,
1983                    hash_build: f64::MAX,
1984                },
1985            ),
1986            (
1987                "stream + hash",
1988                PlanLimits {
1989                    probe_floor: f64::MAX,
1990                    stream_seed: 0.0,
1991                    hash_build: f64::MAX,
1992                },
1993            ),
1994            (
1995                "stream + probe",
1996                PlanLimits {
1997                    probe_floor: f64::MAX,
1998                    stream_seed: 0.0,
1999                    hash_build: 0.0,
2000                },
2001            ),
2002            (
2003                "stream, all paths",
2004                PlanLimits {
2005                    probe_floor: 0.0,
2006                    stream_seed: 0.0,
2007                    hash_build: 30.0,
2008                },
2009            ),
2010        ] {
2011            assert_eq!(run(Some(l)), reference, "{name}");
2012        }
2013    }
2014
2015    #[test]
2016    fn single_pattern_binds_variable() {
2017        let bytes = rete_from(&[("Alice", "knows", "Bob"), ("Bob", "knows", "Carol")]);
2018        let rete = Rete::open(&bytes).unwrap();
2019        let sols = eval_bgp(&rete, &[pat("Alice", "knows", "?y")]);
2020        assert_eq!(sols.len(), 1);
2021        assert_eq!(sols[0]["y"], "Bob");
2022    }
2023
2024    #[test]
2025    fn two_hop_join_on_shared_variable() {
2026        // Alice -> Bob -> Carol, plus a dead-end branch.
2027        let bytes = rete_from(&[
2028            ("Alice", "knows", "Bob"),
2029            ("Bob", "knows", "Carol"),
2030            ("Carol", "knows", "Dave"),
2031            ("Alice", "knows", "Eve"), // Eve knows no one -> no 2-hop
2032        ]);
2033        let rete = Rete::open(&bytes).unwrap();
2034        let sols = eval_bgp(&rete, &[pat("?x", "knows", "?y"), pat("?y", "knows", "?z")]);
2035        // Alice-Bob-Carol, Bob-Carol-Dave.
2036        let mut got: Vec<_> = sols
2037            .iter()
2038            .map(|b| (b["x"].clone(), b["y"].clone(), b["z"].clone()))
2039            .collect();
2040        got.sort();
2041        assert_eq!(
2042            got,
2043            vec![
2044                ("Alice".into(), "Bob".into(), "Carol".into()),
2045                ("Bob".into(), "Carol".into(), "Dave".into()),
2046            ]
2047        );
2048    }
2049
2050    #[test]
2051    fn repeated_variable_within_pattern() {
2052        // A mutual/self relation: only Bob knows himself.
2053        let bytes = rete_from(&[("Alice", "knows", "Bob"), ("Bob", "knows", "Bob")]);
2054        let rete = Rete::open(&bytes).unwrap();
2055        let sols = eval_bgp(&rete, &[pat("?x", "knows", "?x")]);
2056        assert_eq!(sols.len(), 1);
2057        assert_eq!(sols[0]["x"], "Bob");
2058    }
2059
2060    #[test]
2061    fn no_solutions_yields_empty() {
2062        let bytes = rete_from(&[("Alice", "knows", "Bob")]);
2063        let rete = Rete::open(&bytes).unwrap();
2064        assert!(eval_bgp(&rete, &[pat("Alice", "likes", "?y")]).is_empty());
2065    }
2066
2067    /// A LUBM-Q7-shaped snowflake: a popular class enumeration (`?x a Student`)
2068    /// plus a selective adjacency seed (`<Prof> teacherOf ?y`). Exercises both the
2069    /// **class-enum seed deprioritization** (the join must not start from the huge
2070    /// `a Student` extent) and the **probe-the-tail hybrid** (the type checks are
2071    /// applied late, per row). The answer must equal a brute-force computation
2072    /// regardless of how the join is ordered/probed.
2073    #[test]
2074    fn snowflake_type_and_adjacency_matches_reference() {
2075        const TYPE: &str = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>";
2076        let mut triples: Vec<(String, String, String)> = Vec::new();
2077        // 60 students, each a Student and taking 2 courses (c{i%5}, c{(i+1)%5}).
2078        for i in 0..60 {
2079            let s = format!("S{i}");
2080            triples.push((s.clone(), TYPE.into(), "Student".into()));
2081            triples.push((s.clone(), "takesCourse".into(), format!("c{}", i % 5)));
2082            triples.push((s, "takesCourse".into(), format!("c{}", (i + 1) % 5)));
2083        }
2084        // 5 courses; the professor teaches only c0 and c2.
2085        for j in 0..5 {
2086            triples.push((format!("c{j}"), TYPE.into(), "Course".into()));
2087        }
2088        triples.push(("Prof".into(), "teacherOf".into(), "c0".into()));
2089        triples.push(("Prof".into(), "teacherOf".into(), "c2".into()));
2090
2091        let refs: Vec<(&str, &str, &str)> = triples
2092            .iter()
2093            .map(|(s, p, o)| (s.as_str(), p.as_str(), o.as_str()))
2094            .collect();
2095        let bytes = rete_from(&refs);
2096        let rete = Rete::open(&bytes).unwrap();
2097
2098        let got: std::collections::BTreeSet<(String, String)> = eval_bgp(
2099            &rete,
2100            &[
2101                pat("?x", TYPE, "Student"),
2102                pat("?y", TYPE, "Course"),
2103                pat("?x", "takesCourse", "?y"),
2104                pat("Prof", "teacherOf", "?y"),
2105            ],
2106        )
2107        .into_iter()
2108        .map(|b| (b["x"].clone(), b["y"].clone()))
2109        .collect();
2110
2111        // Brute force: students taking a course the professor teaches (c0 or c2).
2112        let taught = ["c0", "c2"];
2113        let mut want = std::collections::BTreeSet::new();
2114        for i in 0..60 {
2115            for c in [i % 5, (i + 1) % 5] {
2116                let course = format!("c{c}");
2117                if taught.contains(&course.as_str()) {
2118                    want.insert((format!("S{i}"), course));
2119                }
2120            }
2121        }
2122        assert_eq!(got, want, "snowflake join must match the brute-force set");
2123        assert!(!want.is_empty(), "sanity: the reference set is non-empty");
2124    }
2125
2126    /// A subject-subject join (`?x a ?av . ?x b ?bv`) is the same-role shape the
2127    /// merge join handles: both patterns sort on the subject column. With multiple
2128    /// `a` and `b` values per subject it must emit the full cross product per
2129    /// subject (equal-key run handling) and drop subjects missing either side.
2130    #[test]
2131    fn merge_join_subject_star_cross_product() {
2132        let bytes = rete_from(&[
2133            ("x", "a", "a1"),
2134            ("x", "a", "a2"),
2135            ("x", "b", "b1"),
2136            ("x", "b", "b2"),
2137            ("y", "a", "a3"),
2138            ("y", "b", "b3"),
2139            ("z", "a", "a4"), // z has no `b` → contributes no join row
2140        ]);
2141        let rete = Rete::open(&bytes).unwrap();
2142        let mut got: Vec<(String, String, String)> =
2143            eval_bgp(&rete, &[pat("?x", "a", "?av"), pat("?x", "b", "?bv")])
2144                .iter()
2145                .map(|m| (m["x"].clone(), m["av"].clone(), m["bv"].clone()))
2146                .collect();
2147        got.sort();
2148        let mut want: Vec<(String, String, String)> = [
2149            ("x", "a1", "b1"),
2150            ("x", "a1", "b2"),
2151            ("x", "a2", "b1"),
2152            ("x", "a2", "b2"),
2153            ("y", "a3", "b3"),
2154        ]
2155        .into_iter()
2156        .map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string()))
2157        .collect();
2158        want.sort();
2159        assert_eq!(got, want, "same-role merge must equal the brute-force join");
2160    }
2161}