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    let (lo, hi) = {
297        let (a, b) = (pattern_scan_bytes(index, ta), pattern_scan_bytes(index, tb));
298        (a.min(b), a.max(b))
299    };
300    if hi >= FAT_SCAN_BYTES && hi / 4 >= lo {
301        return None;
302    }
303    let lower = |t: &(SlotTerm, SlotTerm, SlotTerm)| -> Option<Pattern> {
304        Some((
305            const_subject(&t.0, dict)?,
306            const_predicate(&t.1)?,
307            const_object(&t.2, dict)?,
308        ))
309    };
310    // Materialize each side as `(join-key, row)`, already ascending in the join
311    // column (the index streams it sorted there).
312    let collect = |t: &(SlotTerm, SlotTerm, SlotTerm), col: usize| -> Option<Vec<(u32, Row)>> {
313        let pat = lower(t)?;
314        let mut out = Vec::new();
315        for tri in index.scan_iter_sorted_on(pat, col)? {
316            if let Some(r) = triple_row(ctx, t, tri) {
317                out.push(([tri.0, tri.1, tri.2][col], r));
318            }
319        }
320        Some(out)
321    };
322    let rows_a = collect(ta, ca)?;
323    let rows_b = collect(tb, cb)?;
324
325    let mut out: Vec<Row> = Vec::new();
326    let (mut i, mut j) = (0usize, 0usize);
327    while i < rows_a.len() && j < rows_b.len() {
328        match rows_a[i].0.cmp(&rows_b[j].0) {
329            std::cmp::Ordering::Less => i += 1,
330            std::cmp::Ordering::Greater => j += 1,
331            std::cmp::Ordering::Equal => {
332                let key = rows_a[i].0;
333                let (i0, j0) = (i, j);
334                while i < rows_a.len() && rows_a[i].0 == key {
335                    i += 1;
336                }
337                while j < rows_b.len() && rows_b[j].0 == key {
338                    j += 1;
339                }
340                for (_, ra) in &rows_a[i0..i] {
341                    for (_, rb) in &rows_b[j0..j] {
342                        // Combine the two rows; they agree on the shared slot `v`
343                        // (same key ⇒ same node value at the same column).
344                        let mut row = ra.clone();
345                        let mut ok = true;
346                        for (slot, val) in rb.iter().enumerate() {
347                            if let Some(val) = val {
348                                match &row[slot] {
349                                    Some(existing) if existing != val => {
350                                        ok = false;
351                                        break;
352                                    }
353                                    _ => row[slot] = Some(val.clone()),
354                                }
355                            }
356                        }
357                        if ok {
358                            out.push(row);
359                        }
360                    }
361                }
362            }
363        }
364    }
365    let mut slots = pattern_slots(ta);
366    for s in pattern_slots(tb) {
367        if !slots.contains(&s) {
368            slots.push(s);
369        }
370    }
371    Some((out, slots))
372}
373
374/// Probe the join tail (instead of full-scanning) once the running BGP result is
375/// at most this many rows: each remaining pattern that shares a bound variable is
376/// then resolved by `rows × index-lookup` rather than a whole-extent scan. Kept
377/// small so the probe count is always cheap in absolute terms — the dominant win
378/// is the large-scan-after-tiny-prefix case (a `?x a C` over thousands when the
379/// prefix already pinned ?x to a few dozen rows); a *moderately* large prefix
380/// keeps the one-pass scan + hash join, which beats thousands of probes.
381const BGP_PROBE_THRESHOLD: usize = 512;
382
383/// A pattern whose estimated scan is below this many triples is never worth
384/// planning around: scanning it is cheap whatever the other side holds. Keeps
385/// every small graph (the W3C suite, the playground datasets) on the joins it
386/// always used.
387const PROBE_SCAN_FLOOR: f64 = 65_536.0;
388
389/// Probe instead of scanning once the scan is at least this many times larger
390/// than the rows that would probe it. A probe is a tile-directory binary search
391/// plus a group lookup in an (often cached) tile; a scan pays per triple, and
392/// on the hash-join path also materializes every one of them.
393const PROBE_RATIO: f64 = 16.0;
394
395/// Is probing `rows` bound rows cheaper than scanning a pattern estimated at
396/// `scan_est` triples?
397fn probe_beats_scan(rows: usize, scan_est: f64) -> bool {
398    scan_est >= limits().probe_floor && (rows as f64) * PROBE_RATIO <= scan_est
399}
400
401/// The planner's size thresholds, gathered so tests can shrink them and drive
402/// every join path over a graph small enough to check by hand.
403#[derive(Clone, Copy)]
404struct PlanLimits {
405    probe_floor: f64,
406    stream_seed: f64,
407    hash_build: f64,
408}
409
410#[cfg(test)]
411thread_local! {
412    static TEST_LIMITS: std::cell::Cell<Option<PlanLimits>> = const { std::cell::Cell::new(None) };
413}
414
415fn limits() -> PlanLimits {
416    #[cfg(test)]
417    if let Some(l) = TEST_LIMITS.with(|c| c.get()) {
418        return l;
419    }
420    PlanLimits {
421        probe_floor: PROBE_SCAN_FLOOR,
422        stream_seed: STREAM_SEED_ROWS,
423        hash_build: HASH_BUILD_ROWS,
424    }
425}
426
427/// A BGP whose most selective pattern is estimated above this many matches is
428/// **streamed** rather than joined into a materialized prefix
429/// ([`BgpSolutions`]): every later pattern is then joined per row (probe) or
430/// against a small materialized pattern (hash), so resident memory no longer
431/// follows the largest pattern. `?s dcite:doi ?d . ?s dcite:fromSnapshot ?x`
432/// materialized 109 M rows before joining and ran a 16 GB container out of
433/// memory.
434const STREAM_SEED_ROWS: f64 = (1u64 << 21) as f64;
435
436/// On the streaming path, a later pattern estimated at no more than this many
437/// matches is scanned once into a hash table; a larger one is probed per row.
438const HASH_BUILD_ROWS: f64 = (1u64 << 20) as f64;
439
440/// Tile-directory cardinality estimates ([`GraphIndex::estimate_matches`]) for
441/// each lowered pattern. A variable an outer seed row binds is a constant at
442/// run time but unknown now: it scales the estimate by the same default
443/// selectivities [`pattern_estimates`] falls back to. An unsatisfiable
444/// constant estimates 0.
445fn tile_estimates(
446    ctx: &Ctx,
447    index: &GraphIndex,
448    lowered: &[(SlotTerm, SlotTerm, SlotTerm)],
449    seed: &std::collections::HashSet<usize>,
450) -> Vec<f64> {
451    const SEL_SUBJECT: f64 = 0.001;
452    const SEL_OBJECT: f64 = 0.02;
453    const SEL_PREDICATE: f64 = 0.05;
454    let dict = ctx.rete.dictionary();
455    let total = ctx.rete.header().quad_count;
456    let seeded = |x: &SlotTerm| matches!(x, SlotTerm::Var(v) if seed.contains(v));
457    lowered
458        .iter()
459        .map(|t| {
460            let (Some(s), Some(p), Some(o)) = (
461                const_subject(&t.0, dict),
462                const_predicate(&t.1),
463                const_object(&t.2, dict),
464            ) else {
465                return 0.0;
466            };
467            let mut est = index.estimate_matches((s, p, o), total);
468            if est == 0.0 {
469                return 0.0;
470            }
471            if seeded(&t.0) {
472                est *= SEL_SUBJECT;
473            }
474            if seeded(&t.1) {
475                est *= SEL_PREDICATE;
476            }
477            if seeded(&t.2) {
478                est *= SEL_OBJECT;
479            }
480            est.max(1.0)
481        })
482        .collect()
483}
484
485/// Remote (lazy) probe bounds. Over HTTP byte-range reads, a per-row probe is a
486/// network round-trip, so we probe far more reluctantly than in memory: only when
487/// scanning the next pattern would be a *broad* read (a whole-predicate scan,
488/// [`pattern_is_selective`] is false), or the prefix is tiny enough that a handful
489/// of probes is trivial either way. A pattern that carries its own bound
490/// subject/object routes to a narrow index range — cheaper to scan once and
491/// hash-join than to probe per prefix row. Probing a large prefix is always a
492/// loss remotely, so it is capped.
493const REMOTE_PROBE_MIN: usize = 8;
494const REMOTE_PROBE_MAX: usize = 1024;
495
496/// The remote probe budget, widened by the reader's concurrency: a serial
497/// sync-XHR reader (a phone without the COI pool) pays one full round trip per
498/// probe, so its budget stays at [`REMOTE_PROBE_MAX`]; a reader overlapping
499/// 16 range reads (the CLI's thread pool, the asyncified fetch variant, the
500/// COI fetch-worker pool) amortizes ~16 probes per round trip and gets a
501/// proportionally larger budget before the one-pass scan wins again.
502fn remote_probe_max(index: &GraphIndex) -> usize {
503    REMOTE_PROBE_MAX * index.read_concurrency().clamp(1, 16)
504}
505
506/// Batch-fault the tiles that a [`ProbePlan`]'s FIRST pattern will route to
507/// across a batch of seed rows — one coalesced read per section instead of a
508/// blocking round trip per row. Only the first pattern is prefetchable (later
509/// ones bind from earlier probe results), and only rows whose seed binds the
510/// pattern's subject or object are included: a predicate-only pattern routes
511/// to the predicate's WHOLE span, which for the fat sides this path serves
512/// would prefetch the very extent the probe strategy exists to avoid.
513pub(crate) fn prefetch_plan_probes(ctx: &Ctx, index: &GraphIndex, plan: &ProbePlan, rows: &[Row]) {
514    if !index.is_remote() {
515        return;
516    }
517    let Some(t) = plan.pats.first() else {
518        return;
519    };
520    let mut pats: Vec<Pattern> = Vec::new();
521    for base in rows {
522        if let (Some(s), Some(p), Some(o)) = (
523            probe_subject(ctx, &t.0, base),
524            probe_predicate(ctx, &t.1, base),
525            probe_object(ctx, &t.2, base),
526        ) {
527            if s.is_some() || o.is_some() {
528                pats.push((s, p, o));
529            }
530        }
531    }
532    index.prefetch_probe_tiles(&pats);
533}
534
535/// Batch-fault the tiles that probing `patterns` from `subject_ids` (each
536/// bound onto the subject variable `sv`) will route to — one coalesced read
537/// per section instead of one round trip per candidate. Purely a cache
538/// warmer: correctness is untouched, and a local index is a no-op. Used by
539/// the FILTER-CONTAINS pushdown before it probes its candidate subjects.
540pub(crate) fn prefetch_subject_probes(
541    ctx: &Ctx,
542    index: &GraphIndex,
543    patterns: &[TriplePattern],
544    sv: &str,
545    subject_ids: &[u32],
546) {
547    if !index.is_remote() {
548        return;
549    }
550    let Some(lowered) = lower(patterns, ctx) else {
551        return;
552    };
553    let Some(slot) = ctx.slots.slot(sv) else {
554        return;
555    };
556    let dict = ctx.rete.dictionary();
557    let mut pats: Vec<Pattern> = Vec::new();
558    for &sid in subject_ids {
559        let mut base = ctx.slots.empty_row();
560        base[slot] = Some(Val::Id(dict.subject_node(sid) as i64));
561        for t in &lowered {
562            if !matches!(t.0, SlotTerm::Var(i) if i == slot) {
563                continue;
564            }
565            if let (Some(s), Some(p), Some(o)) = (
566                probe_subject(ctx, &t.0, &base),
567                probe_predicate(ctx, &t.1, &base),
568                probe_object(ctx, &t.2, &base),
569            ) {
570                pats.push((s, p, o));
571            }
572        }
573    }
574    index.prefetch_probe_tiles(&pats);
575}
576
577/// A pattern whose own constant terms (a bound subject or object) already route
578/// it to a narrow index range — a few coalesced range reads to scan in full.
579fn pattern_is_selective(t: &(SlotTerm, SlotTerm, SlotTerm)) -> bool {
580    matches!(t.0, SlotTerm::Node(_)) || matches!(t.2, SlotTerm::Node(_))
581}
582
583/// The (deduped) slots a lowered pattern binds.
584fn pattern_slots(t: &(SlotTerm, SlotTerm, SlotTerm)) -> Vec<usize> {
585    let mut slots: Vec<usize> = Vec::new();
586    for term in [&t.0, &t.1, &t.2] {
587        if let SlotTerm::Var(i) = term {
588            if !slots.contains(i) {
589                slots.push(*i);
590            }
591        }
592    }
593    slots
594}
595
596/// Build a solution row for one scanned triple, enforcing repeated variables
597/// *within* the pattern (e.g. `?x p ?x`). `None` = the triple doesn't satisfy
598/// a repeated variable.
599fn triple_row(
600    ctx: &Ctx,
601    t: &(SlotTerm, SlotTerm, SlotTerm),
602    (s_id, p_id, o_id): (u32, u32, u32),
603) -> Option<Row> {
604    let dict = ctx.rete.dictionary();
605    let s_val = dict.subject_node(s_id) as i64;
606    let p_val = ctx.resolver.canon_id(pred_tag(p_id));
607    let o_val = dict.object_node(o_id) as i64;
608    let mut row = ctx.slots.empty_row();
609    for (term, val) in [(&t.0, s_val), (&t.1, p_val), (&t.2, o_val)] {
610        if let SlotTerm::Var(i) = term {
611            match row[*i] {
612                Some(Val::Id(existing)) if existing != val => return None,
613                Some(_) => {}
614                None => row[*i] = Some(Val::Id(val)),
615            }
616        }
617    }
618    Some(row)
619}
620
621/// Lazily scan one lowered pattern as a stream of solution rows. The scan
622/// constrains only the pattern's constant terms (bound variables are joined in
623/// afterwards, not pushed into the scan). `None` = a constant is unsatisfiable
624/// (unknown to the dictionary or in an impossible role), which empties the BGP.
625fn scan_rows<'q>(
626    ctx: &'q Ctx<'q>,
627    index: &'q GraphIndex,
628    t: (SlotTerm, SlotTerm, SlotTerm),
629) -> Option<impl Iterator<Item = Row> + 'q> {
630    let dict = ctx.rete.dictionary();
631    let (sid, pid, oid) = (
632        const_subject(&t.0, dict)?,
633        const_predicate(&t.1)?,
634        const_object(&t.2, dict)?,
635    );
636    Some(
637        index
638            .scan_iter((sid, pid, oid))
639            .filter_map(move |triple| triple_row(ctx, &t, triple)),
640    )
641}
642
643/// Scan one lowered pattern into a materialized relation, returning the rows
644/// and the slots the pattern binds. `None` = a constant is unsatisfiable.
645fn pattern_rows(
646    ctx: &Ctx,
647    index: &GraphIndex,
648    t: &(SlotTerm, SlotTerm, SlotTerm),
649) -> Option<(Vec<Row>, Vec<usize>)> {
650    let slots = pattern_slots(t);
651    let mut rel: Vec<Row> = Vec::new();
652    let dict = ctx.rete.dictionary();
653    let (sid, pid, oid) = (
654        const_subject(&t.0, dict)?,
655        const_predicate(&t.1)?,
656        const_object(&t.2, dict)?,
657    );
658    // Stream the matches with the lazy cursor — the hash join is
659    // order-independent, so no canonical re-sort is needed here.
660    for triple in index.scan_iter((sid, pid, oid)) {
661        if let Some(row) = triple_row(ctx, t, triple) {
662            rel.push(row);
663        }
664    }
665    Some((rel, slots))
666}
667
668/// A per-pattern cardinality estimate, smaller = cheaper to scan. The base is
669/// the **exact** per-predicate triple count (sum of the summary quotient graph's
670/// super-edge counts) when the predicate is bound, else the whole-graph total.
671/// A bound subject/object (a constant, or a variable already bound by `seed`)
672/// then scales it down by that predicate's **measured selectivity** from the
673/// `query_stats` block — `1 / distinct_subjects` for a bound subject (so
674/// `<s> <p> ?o` ≈ the average objects per subject; exactly 1 for a functional
675/// predicate), `1 / distinct_objects` for a bound object. Files built before the
676/// `query_stats` block fall back to fixed default selectivities. `None` when the
677/// file carries no pyramid summary at all — ordering then uses the constant-count
678/// heuristic.
679fn pattern_estimates(
680    ctx: &Ctx,
681    lowered: &[(SlotTerm, SlotTerm, SlotTerm)],
682    seed: &std::collections::HashSet<usize>,
683) -> Option<Vec<f64>> {
684    // Only use the summary when it's already in memory — never fault it just to
685    // plan (the lazy remote path defers the pyramid by design).
686    let pyr = ctx.rete.pyramid_if_loaded()?;
687    let mut pred: std::collections::HashMap<u32, u64> = std::collections::HashMap::new();
688    for e in &pyr.summary {
689        *pred.entry(e.predicate).or_insert(0) += e.count as u64;
690    }
691    // Measured per-predicate distinct subjects/objects (query_stats block; empty
692    // on files built before it existed → fall back to the default selectivities).
693    let stats: std::collections::HashMap<u32, &crate::meta::PredStat> = pyr
694        .predicate_stats
695        .iter()
696        .map(|s| (s.predicate, s))
697        .collect();
698    let total = ctx.rete.header().quad_count.max(1) as f64;
699    let num_preds = pred.len().max(1) as f64;
700    // Defaults used only when query_stats has no entry for the predicate.
701    const SEL_SUBJECT: f64 = 0.001;
702    const SEL_OBJECT: f64 = 0.02;
703    let node_bound = |t: &SlotTerm| match t {
704        SlotTerm::Node(_) => true,
705        SlotTerm::Var(v) => seed.contains(v),
706        SlotTerm::Pred(_) => false,
707    };
708    Some(
709        lowered
710            .iter()
711            .map(|t| {
712                // Base from the predicate: exact total for a constant predicate,
713                // the average predicate total for a seed-bound predicate variable
714                // (the value is known to the probe but not at plan time), else the
715                // whole graph. `st` is the measured stats for a constant predicate.
716                let (base, st) = match t.1 {
717                    SlotTerm::Pred(p) => {
718                        (*pred.get(&p).unwrap_or(&0) as f64, stats.get(&p).copied())
719                    }
720                    SlotTerm::Var(v) if seed.contains(&v) => (total / num_preds, None),
721                    _ => (total, None),
722                };
723                let mut est = base.max(1.0);
724                if node_bound(&t.0) {
725                    est *= match st {
726                        Some(s) if s.distinct_subjects > 0 => 1.0 / s.distinct_subjects as f64,
727                        _ => SEL_SUBJECT,
728                    };
729                }
730                if node_bound(&t.2) {
731                    est *= match st {
732                        Some(s) if s.distinct_objects > 0 => 1.0 / s.distinct_objects as f64,
733                        _ => SEL_OBJECT,
734                    };
735                }
736                est.max(1.0)
737            })
738            .collect(),
739    )
740}
741
742/// The minimum, over a BGP's patterns, of [`pattern_scan_bytes`] — the
743/// cheapest single-pattern scan the hash-join path would at least perform to
744/// materialize this BGP. `None` = a constant term is unknown to the
745/// dictionary (the BGP is unsatisfiable; the caller short-circuits anyway).
746/// Unlike [`pattern_estimates`], this needs no pyramid summary, so it works on
747/// remote-lazy files — exactly where the answer matters most.
748pub(crate) fn bgp_min_scan_bytes(
749    ctx: &Ctx,
750    index: &GraphIndex,
751    patterns: &[TriplePattern],
752) -> Option<u64> {
753    let lowered = lower(patterns, ctx)?;
754    lowered.iter().map(|t| pattern_scan_bytes(index, t)).min()
755}
756
757/// One pattern's cheapest scan extent in BYTES: for each permutation, sum the
758/// encoded lengths of the tiles a scan of this pattern would route to (all
759/// tiles covering the bound leading key, or the whole section when the leading
760/// key is unbound), and take the minimum. Tile *counts* are useless here — a
761/// broad predicate can sit inside one giant tile — but the directory's byte
762/// lengths expose it. Reads only resident metadata, never tile data.
763fn pattern_scan_bytes(index: &GraphIndex, t: &(SlotTerm, SlotTerm, SlotTerm)) -> u64 {
764    let sections = index.tile_sections();
765    let comp = |role: usize| match role {
766        0 => &t.0,
767        1 => &t.1,
768        _ => &t.2,
769    };
770    let mut best = u64::MAX;
771    // Only the permutations the FILE carries: an absent section is an empty
772    // `Vec<Tile>`, and summing its (zero) tile lengths would report a free scan
773    // for the one permutation that cannot be scanned at all.
774    for perm in index.perms().iter() {
775        let tiles = sections[perm.section_index()];
776        best = best.min(match comp(perm.roles()[0]) {
777            SlotTerm::Node(id) | SlotTerm::Pred(id) => tiles
778                .iter()
779                .filter(|tile| {
780                    let (lo, hi) = tile.leading_range();
781                    lo <= *id && *id <= hi
782                })
783                .map(Tile::encoded_len)
784                .sum(),
785            SlotTerm::Var(_) => tiles.iter().map(Tile::encoded_len).sum(),
786        });
787    }
788    best
789}
790
791/// Scan extent above which a join side counts as "fat": materializing (or, on
792/// a remote index, fetching) ≥2 MiB of encoded tiles to build a hash/merge
793/// side is past the point where probing it per surviving row wins. Shared by
794/// the merge-seed asymmetry gate here and the left-join force-probe gate in
795/// `sparql::eval`.
796pub(crate) const FAT_SCAN_BYTES: u64 = 2 << 20;
797
798/// Order pattern indices for a left-deep join: cheapest (smallest estimated
799/// cardinality) first, always preferring a pattern that shares a variable with
800/// the already-joined set so the join stays connected and intermediate relations
801/// stay small. Cardinality comes from [`pattern_estimates`]; with no summary it
802/// falls back to a most-constants-first heuristic. Pure reordering — `hash_join`
803/// is order-independent, so the result multiset is unchanged.
804fn selectivity_order(
805    ctx: &Ctx,
806    index: &GraphIndex,
807    lowered: &[(SlotTerm, SlotTerm, SlotTerm)],
808) -> Vec<usize> {
809    selectivity_order_seeded(ctx, index, lowered, &std::collections::HashSet::new())
810}
811
812/// [`selectivity_order`] with a set of slots already bound by an outer seed
813/// row: those variables count as already connected (and, in the no-summary
814/// fallback, as constants for selectivity).
815fn selectivity_order_seeded(
816    ctx: &Ctx,
817    index: &GraphIndex,
818    lowered: &[(SlotTerm, SlotTerm, SlotTerm)],
819    seed: &std::collections::HashSet<usize>,
820) -> Vec<usize> {
821    // Exact per-predicate statistics when the pyramid is resident; otherwise
822    // (the lazy path, and files built without a pyramid) the tile-directory
823    // estimate, which needs no fetch. That one also counts a class's own
824    // instances (`?s a :C` routes to the tiles of `:C`), so the
825    // class-enumeration rule below, a stand-in for exactly that missing
826    // number, applies only to the pyramid figures, whose `rdf:type`
827    // selectivity is an average over classes.
828    let (estimates, per_class) = match pattern_estimates(ctx, lowered, seed) {
829        Some(e) => (Some(e), false),
830        None => (Some(tile_estimates(ctx, index, lowered, seed)), true),
831    };
832    let consts = |t: &(SlotTerm, SlotTerm, SlotTerm)| {
833        [&t.0, &t.1, &t.2]
834            .into_iter()
835            .filter(|x| match x {
836                SlotTerm::Var(v) => seed.contains(v),
837                _ => true,
838            })
839            .count()
840    };
841    let vars = |t: &(SlotTerm, SlotTerm, SlotTerm)| -> Vec<usize> {
842        [&t.0, &t.1, &t.2]
843            .into_iter()
844            .filter_map(|x| match x {
845                SlotTerm::Var(v) => Some(*v),
846                _ => None,
847            })
848            .collect()
849    };
850    // Higher score = picked sooner. With summary stats that's the negated
851    // estimate (smaller cardinality wins); without, the constant count (old
852    // behaviour, byte-identical ordering for pyramid-less files).
853    let score = |i: usize| -> f64 {
854        match &estimates {
855            Some(e) => -e[i],
856            None => consts(&lowered[i]) as f64,
857        }
858    };
859    // A `?s rdf:type <Class>` with an as-yet-unbound subject is a **class
860    // enumeration**: it matches every instance of the class, so it is almost never
861    // the most selective place to *start* a join (a popular class has thousands of
862    // instances), even though it has two bound positions and so looks selective to
863    // the constant-count heuristic. Deprioritize it as a seed; once its subject is
864    // bound by another pattern it becomes a cheap per-row type *check* (the hybrid
865    // join probes it), so pushing it later costs nothing. Robust and stats-free.
866    let type_pid = ctx.rete.dictionary().predicate_id(crate::file::RDF_TYPE);
867    let is_class_enum = |i: usize, bound: &std::collections::HashSet<usize>| -> bool {
868        let t = &lowered[i];
869        !per_class
870            && type_pid.is_some_and(|tp| matches!(t.1, SlotTerm::Pred(p) if p == tp))
871            && matches!(t.2, SlotTerm::Node(_))
872            && matches!(t.0, SlotTerm::Var(v) if !bound.contains(&v))
873    };
874    let n = lowered.len();
875    let mut remaining: Vec<usize> = (0..n).collect();
876    let mut order: Vec<usize> = Vec::with_capacity(n);
877    let mut bound: std::collections::HashSet<usize> = seed.clone();
878    while !remaining.is_empty() {
879        // Pick the remaining pattern with the best (connected, not-a-class-enum,
880        // score) key; ties go to the lowest original index for a stable order.
881        let best = *remaining
882            .iter()
883            .max_by(|&&a, &&b| {
884                let connected = |i: usize| vars(&lowered[i]).iter().any(|v| bound.contains(v));
885                connected(a)
886                    .cmp(&connected(b))
887                    .then_with(|| is_class_enum(b, &bound).cmp(&is_class_enum(a, &bound)))
888                    .then_with(|| {
889                        score(a)
890                            .partial_cmp(&score(b))
891                            .unwrap_or(std::cmp::Ordering::Equal)
892                    })
893                    .then_with(|| b.cmp(&a))
894            })
895            .unwrap();
896        for v in vars(&lowered[best]) {
897            bound.insert(v);
898        }
899        order.push(best);
900        remaining.retain(|&i| i != best);
901    }
902    order
903}
904
905/// Does the BGP have at least one solution against `index`? A single pattern
906/// with all-distinct variables streams the index and stops at the first match
907/// (no materialization); anything else falls back to the full evaluator and
908/// tests non-emptiness (still benefiting from the lazy per-pattern scan). Used
909/// by `ASK`.
910pub(crate) fn bgp_exists(ctx: &Ctx, index: &GraphIndex, patterns: &[TriplePattern]) -> bool {
911    let dict = ctx.rete.dictionary();
912    let Some(lowered) = lower(patterns, ctx) else {
913        return false;
914    };
915    if let [t] = lowered.as_slice() {
916        // The fast path can't enforce a variable repeated across positions
917        // (e.g. `?x p ?x`) — that needs the row builder — so only take it when
918        // the pattern's variables are all distinct.
919        let names: Vec<usize> = [&t.0, &t.1, &t.2]
920            .into_iter()
921            .filter_map(|x| match x {
922                SlotTerm::Var(v) => Some(*v),
923                _ => None,
924            })
925            .collect();
926        let distinct = names
927            .iter()
928            .enumerate()
929            .all(|(i, v)| !names[i + 1..].contains(v));
930        if distinct {
931            return match (
932                const_subject(&t.0, dict),
933                const_predicate(&t.1),
934                const_object(&t.2, dict),
935            ) {
936                (Some(s), Some(p), Some(o)) => index.scan_iter((s, p, o)).next().is_some(),
937                _ => false,
938            };
939        }
940    }
941    !eval_bgp_rows(ctx, index, patterns).is_empty()
942}
943
944/// A lazy left-deep BGP join that yields solution rows one at a time, so a
945/// consumer under `LIMIT`/`OFFSET` (or `.next()` for ASK) can stop early —
946/// stopping also stops the underlying index scan.
947///
948/// The all-but-last patterns are joined eagerly into a `prefix` **hash table**
949/// (they are the most selective, so this is the small side); the *last* (least
950/// selective) pattern is then **streamed** from the index cursor, probing the
951/// prefix — the big scan is never materialized, and a `LIMIT` above stops it
952/// after a handful of triples. Yields the same solution multiset as
953/// [`eval_bgp_rows`] (the join is order-independent), just incrementally.
954pub(crate) struct BgpSolutions<'q> {
955    scan: Option<Box<dyn Iterator<Item = Row> + 'q>>,
956    prefix: Vec<Row>,
957    /// Prefix row indices keyed by the shared-slot values.
958    buckets: HashMap<Vec<Val>, Vec<usize>>,
959    shared: Vec<usize>,
960    cartesian: bool,
961    /// The prefix is the all-unbound seed row (single-pattern BGP): scanned
962    /// rows are already complete solutions — pass them through unmerged.
963    seed_only: bool,
964    cur_scan: Option<Row>,
965    /// Reused candidate buffer (avoids a Vec allocation per scanned row).
966    matches: Vec<usize>,
967    mi: usize,
968}
969
970impl<'q> BgpSolutions<'q> {
971    /// Yield `rows` as complete solutions (no prefix merge).
972    fn passthrough(rows: Box<dyn Iterator<Item = Row> + 'q>) -> Self {
973        BgpSolutions {
974            scan: Some(rows),
975            prefix: Vec::new(),
976            buckets: HashMap::new(),
977            shared: Vec::new(),
978            cartesian: false,
979            seed_only: true,
980            cur_scan: None,
981            matches: Vec::new(),
982            mi: 0,
983        }
984    }
985
986    /// The fully streaming join, for a BGP whose most selective pattern is
987    /// itself large ([`STREAM_SEED_ROWS`]). Nothing proportional to it is ever
988    /// held: the seed pattern streams from its index cursor (or, when the next
989    /// pattern is just as large and shares its subject variable, both stream
990    /// through a merge join), and each later pattern joins per row — through
991    /// a hash table when the pattern is small ([`HASH_BUILD_ROWS`]), by an
992    /// index probe otherwise. Same solution multiset as the materializing
993    /// path; only the row order differs.
994    fn streaming(
995        ctx: &'q Ctx<'q>,
996        index: &'q GraphIndex,
997        lowered: &[(SlotTerm, SlotTerm, SlotTerm)],
998        order: &[usize],
999        est: &[f64],
1000    ) -> Self {
1001        let (mut stream, mut bound, skip): (Box<dyn Iterator<Item = Row> + 'q>, Vec<usize>, usize) =
1002            match (est[order[1]] > limits().stream_seed)
1003                .then(|| merge_stream(ctx, index, lowered[order[0]], lowered[order[1]]))
1004                .flatten()
1005            {
1006                Some((m, slots)) => (m, slots, 2),
1007                None => {
1008                    let t = lowered[order[0]];
1009                    let Some(scan) = scan_rows(ctx, index, t) else {
1010                        return Self::empty();
1011                    };
1012                    (Box::new(scan), pattern_slots(&t), 1)
1013                }
1014            };
1015        for &i in &order[skip..] {
1016            let t = lowered[i];
1017            let slots = pattern_slots(&t);
1018            let shared: Vec<usize> = slots
1019                .iter()
1020                .copied()
1021                .filter(|s| bound.contains(s))
1022                .collect();
1023            if !shared.is_empty() && est[i] > limits().hash_build {
1024                stream = Box::new(stream.flat_map(move |base| probe_rows(ctx, index, t, base)));
1025            } else {
1026                // Small (or unconnected) pattern: scan it once into a table
1027                // keyed on the shared slots and join every streamed row to it.
1028                let Some((rel, _)) = pattern_rows(ctx, index, &t) else {
1029                    return Self::empty();
1030                };
1031                let mut table: HashMap<Vec<Val>, Vec<Row>> = HashMap::new();
1032                for r in rel {
1033                    let key: Vec<Val> = shared.iter().map(|&s| r[s].clone().unwrap()).collect();
1034                    table.entry(key).or_default().push(r);
1035                }
1036                let fill = slots.clone();
1037                stream = Box::new(stream.flat_map(move |base| {
1038                    let key: Vec<Val> = shared.iter().map(|&s| base[s].clone().unwrap()).collect();
1039                    let out: Vec<Row> = table
1040                        .get(&key)
1041                        .map(|rs| {
1042                            rs.iter()
1043                                .map(|r| {
1044                                    let mut row = base.clone();
1045                                    for &s in &fill {
1046                                        row[s] = r[s].clone();
1047                                    }
1048                                    row
1049                                })
1050                                .collect()
1051                        })
1052                        .unwrap_or_default();
1053                    out.into_iter()
1054                }));
1055            }
1056            for s in slots {
1057                if !bound.contains(&s) {
1058                    bound.push(s);
1059                }
1060            }
1061        }
1062        Self::passthrough(stream)
1063    }
1064
1065    /// An iterator that yields nothing (an unsatisfiable BGP).
1066    fn empty() -> Self {
1067        BgpSolutions {
1068            scan: None,
1069            prefix: Vec::new(),
1070            buckets: HashMap::new(),
1071            shared: Vec::new(),
1072            cartesian: false,
1073            seed_only: false,
1074            cur_scan: None,
1075            matches: Vec::new(),
1076            mi: 0,
1077        }
1078    }
1079
1080    pub(crate) fn new(ctx: &'q Ctx<'q>, index: &'q GraphIndex, patterns: &[TriplePattern]) -> Self {
1081        // An empty BGP has exactly one (empty) solution.
1082        if patterns.is_empty() {
1083            return BgpSolutions {
1084                scan: Some(Box::new(std::iter::once(ctx.slots.empty_row()))),
1085                prefix: vec![ctx.slots.empty_row()],
1086                buckets: HashMap::new(),
1087                shared: Vec::new(),
1088                cartesian: true,
1089                seed_only: true,
1090                cur_scan: None,
1091                matches: Vec::new(),
1092                mi: 0,
1093            };
1094        }
1095        // Lower all patterns; an unknown constant term ⇒ no solutions.
1096        let Some(lowered) = lower(patterns, ctx) else {
1097            return Self::empty();
1098        };
1099        // Join all but the *last* (least selective) pattern eagerly into the
1100        // prefix; a single pattern leaves the seed row as the prefix.
1101        let order = selectivity_order(ctx, index, &lowered);
1102        let scan_est = tile_estimates(ctx, index, &lowered, &std::collections::HashSet::new());
1103        // Even the most selective pattern is big: materializing it as the
1104        // prefix would hold every one of its rows. Stream the whole join.
1105        if order.len() >= 2 && scan_est[order[0]] > limits().stream_seed {
1106            return Self::streaming(ctx, index, &lowered, &order, &scan_est);
1107        }
1108        let (&last_i, prefix_is) = order.split_last().unwrap();
1109        let prefix_pats: Vec<TriplePattern> =
1110            prefix_is.iter().map(|&i| patterns[i].clone()).collect();
1111        let prefix = eval_bgp_rows(ctx, index, &prefix_pats);
1112        if prefix.is_empty() {
1113            return Self::empty();
1114        }
1115
1116        // Hash-join key = slots shared by the prefix and the last pattern (BGP
1117        // rows bind every slot of their pattern set, so the prefix's bound set
1118        // can be read off its first row).
1119        let shared: Vec<usize> = pattern_slots(&lowered[last_i])
1120            .into_iter()
1121            .filter(|&s| prefix[0][s].is_some())
1122            .collect();
1123
1124        // A few prefix rows against a big last pattern: look each one up
1125        // instead of scanning the pattern. `?r :relationSubject <doi> . ?r ?p
1126        // ?o` has four prefix rows and a last pattern that is the whole graph.
1127        if !shared.is_empty() && probe_beats_scan(prefix.len(), scan_est[last_i]) {
1128            let t = lowered[last_i];
1129            if index.is_remote() {
1130                prefetch_rows_for(ctx, index, t, &prefix);
1131            }
1132            let stream = prefix
1133                .into_iter()
1134                .flat_map(move |base| probe_rows(ctx, index, t, base));
1135            return Self::passthrough(Box::new(stream));
1136        }
1137
1138        // Stream the last pattern with the lazy index cursor.
1139        let Some(scan) = scan_rows(ctx, index, lowered[last_i]) else {
1140            return Self::empty();
1141        };
1142        let cartesian = shared.is_empty();
1143        // A single-pattern BGP joins against the all-unbound seed: scanned rows
1144        // are already complete solutions.
1145        let seed_only = prefix.len() == 1 && prefix[0].iter().all(Option::is_none);
1146        let mut buckets: HashMap<Vec<Val>, Vec<usize>> = HashMap::new();
1147        if !cartesian {
1148            for (i, r) in prefix.iter().enumerate() {
1149                let key: Vec<Val> = shared.iter().map(|&s| r[s].clone().unwrap()).collect();
1150                buckets.entry(key).or_default().push(i);
1151            }
1152        }
1153        BgpSolutions {
1154            scan: Some(Box::new(scan)),
1155            prefix,
1156            buckets,
1157            shared,
1158            cartesian,
1159            seed_only,
1160            cur_scan: None,
1161            matches: Vec::new(),
1162            mi: 0,
1163        }
1164    }
1165}
1166
1167impl Iterator for BgpSolutions<'_> {
1168    type Item = Row;
1169
1170    fn next(&mut self) -> Option<Row> {
1171        // Single-pattern fast path: pass scanned rows through unmerged.
1172        if self.seed_only {
1173            return self.scan.as_mut()?.next();
1174        }
1175        loop {
1176            // Emit the next prefix match for the current scanned row.
1177            if self.mi < self.matches.len() {
1178                let pi = self.matches[self.mi];
1179                self.mi += 1;
1180                let mut merged = self.prefix[pi].clone();
1181                for (slot, v) in self.cur_scan.as_ref().unwrap().iter().enumerate() {
1182                    if v.is_some() {
1183                        merged[slot] = v.clone();
1184                    }
1185                }
1186                return Some(merged);
1187            }
1188            // Pull the next row from the index scan and gather its matches
1189            // (into the reused buffer — no allocation per scanned row).
1190            let s = self.scan.as_mut()?.next()?;
1191            self.matches.clear();
1192            if self.cartesian {
1193                self.matches.extend(0..self.prefix.len());
1194            } else {
1195                let key: Vec<Val> = self.shared.iter().map(|&i| s[i].clone().unwrap()).collect();
1196                if let Some(c) = self.buckets.get(&key) {
1197                    self.matches.extend_from_slice(c);
1198                }
1199            }
1200            self.mi = 0;
1201            self.cur_scan = Some(s);
1202        }
1203    }
1204}
1205
1206/// Batch-fault the tiles that probing `t` from each of `rows` will route to
1207/// (remote readers only; see [`GraphIndex::prefetch_probe_tiles`]).
1208fn prefetch_rows_for(
1209    ctx: &Ctx,
1210    index: &GraphIndex,
1211    t: (SlotTerm, SlotTerm, SlotTerm),
1212    rows: &[Row],
1213) {
1214    let pats: Vec<Pattern> = rows
1215        .iter()
1216        .filter_map(|base| {
1217            match (
1218                probe_subject(ctx, &t.0, base),
1219                probe_predicate(ctx, &t.1, base),
1220                probe_object(ctx, &t.2, base),
1221            ) {
1222                (Some(s), Some(p), Some(o)) => Some((s, p, o)),
1223                _ => None,
1224            }
1225        })
1226        .collect();
1227    index.prefetch_probe_tiles(&pats);
1228}
1229
1230/// A **streaming** merge join of two patterns that share exactly one variable,
1231/// in the subject position of both: each side streams from a permutation
1232/// sorted on subject ([`GraphIndex::scan_iter_sorted_on`]) and only the
1233/// current subject's rows are held. Restricted to the subject column because a
1234/// subject's rows are its out-edges, a small run; an object run (every
1235/// instance of a class) can be the whole pattern. `None` when the shape does
1236/// not qualify; the caller then streams the first pattern alone.
1237#[allow(clippy::type_complexity)]
1238fn merge_stream<'q>(
1239    ctx: &'q Ctx<'q>,
1240    index: &'q GraphIndex,
1241    ta: (SlotTerm, SlotTerm, SlotTerm),
1242    tb: (SlotTerm, SlotTerm, SlotTerm),
1243) -> Option<(Box<dyn Iterator<Item = Row> + 'q>, Vec<usize>)> {
1244    let sa = pattern_slots(&ta);
1245    let sb = pattern_slots(&tb);
1246    let shared: Vec<usize> = sa.iter().copied().filter(|s| sb.contains(s)).collect();
1247    let [v] = shared[..] else {
1248        return None;
1249    };
1250    if !matches!(ta.0, SlotTerm::Var(i) if i == v) || !matches!(tb.0, SlotTerm::Var(i) if i == v) {
1251        return None;
1252    }
1253    let dict = ctx.rete.dictionary();
1254    let lower = |t: &(SlotTerm, SlotTerm, SlotTerm)| -> Option<Pattern> {
1255        Some((
1256            const_subject(&t.0, dict)?,
1257            const_predicate(&t.1)?,
1258            const_object(&t.2, dict)?,
1259        ))
1260    };
1261    let side = |t: (SlotTerm, SlotTerm, SlotTerm)| -> Option<KeyedRows<'q>> {
1262        let it = index.scan_iter_sorted_on(lower(&t)?, 0)?;
1263        let keyed: Box<dyn Iterator<Item = (u32, Row)> + 'q> =
1264            Box::new(it.filter_map(move |tri| triple_row(ctx, &t, tri).map(|r| (tri.0, r))));
1265        Some(keyed.peekable())
1266    };
1267    let a = side(ta)?;
1268    let b = side(tb)?;
1269    let mut slots = sa;
1270    for s in sb {
1271        if !slots.contains(&s) {
1272            slots.push(s);
1273        }
1274    }
1275    Some((
1276        Box::new(MergeStream {
1277            a,
1278            b,
1279            ra: Vec::new(),
1280            rb: Vec::new(),
1281            i: 0,
1282            j: 0,
1283        }),
1284        slots,
1285    ))
1286}
1287
1288type KeyedRows<'q> = std::iter::Peekable<Box<dyn Iterator<Item = (u32, Row)> + 'q>>;
1289
1290/// The two-pointer merge behind [`merge_stream`]: `ra`/`rb` hold the current
1291/// equal-key runs and `(i, j)` walks their cross product lazily.
1292struct MergeStream<'q> {
1293    a: KeyedRows<'q>,
1294    b: KeyedRows<'q>,
1295    ra: Vec<Row>,
1296    rb: Vec<Row>,
1297    i: usize,
1298    j: usize,
1299}
1300
1301impl Iterator for MergeStream<'_> {
1302    type Item = Row;
1303
1304    fn next(&mut self) -> Option<Row> {
1305        loop {
1306            while self.i < self.ra.len() {
1307                if self.j < self.rb.len() {
1308                    let j = self.j;
1309                    self.j += 1;
1310                    if let Some(m) = crate::row::merge_rows(&self.ra[self.i], &self.rb[j]) {
1311                        return Some(m);
1312                    }
1313                } else {
1314                    self.i += 1;
1315                    self.j = 0;
1316                }
1317            }
1318            let ka = self.a.peek()?.0;
1319            let kb = self.b.peek()?.0;
1320            match ka.cmp(&kb) {
1321                std::cmp::Ordering::Less => {
1322                    self.a.next();
1323                }
1324                std::cmp::Ordering::Greater => {
1325                    self.b.next();
1326                }
1327                std::cmp::Ordering::Equal => {
1328                    self.ra.clear();
1329                    self.rb.clear();
1330                    while let Some((_, r)) = self.a.next_if(|(k, _)| *k == ka) {
1331                        self.ra.push(r);
1332                    }
1333                    while let Some((_, r)) = self.b.next_if(|(k, _)| *k == ka) {
1334                        self.rb.push(r);
1335                    }
1336                    self.i = 0;
1337                    self.j = 0;
1338                }
1339            }
1340        }
1341    }
1342}
1343
1344// --- index-nested-loop probing ------------------------------------------------
1345//
1346// Under a small, known demand (LIMIT/ASK — see `Ctx::limit_hint`), scanning
1347// every pattern once to hash-join is mostly wasted work: the consumer wants a
1348// handful of rows. The probe path instead streams the seed/first pattern and,
1349// per row, *probes* each next pattern through the index with the row's bound
1350// values substituted as scan constants — so producing k solutions touches
1351// O(k · patterns) index groups instead of every pattern's full extent. Same
1352// solution multiset as the hash join (joins are order-independent); only the
1353// evaluation order differs.
1354
1355/// Index-scan constant for a subject position given a partially-bound row.
1356/// Outer `None` = unsatisfiable for this row; inner `None` = wildcard.
1357fn probe_subject(ctx: &Ctx, t: &SlotTerm, base: &Row) -> Option<Option<u32>> {
1358    let dict = ctx.rete.dictionary();
1359    match t {
1360        SlotTerm::Node(n) => dict.node_as_subject_id(*n).map(Some),
1361        SlotTerm::Pred(_) => None,
1362        SlotTerm::Var(i) => match &base[*i] {
1363            None => Some(None),
1364            Some(Val::Id(v)) if *v >= 0 => dict.node_as_subject_id(*v as u32).map(Some),
1365            // A predicate-tagged or computed value can never be a subject.
1366            Some(_) => None,
1367        },
1368    }
1369}
1370
1371/// Index-scan constant for an object position (see [`probe_subject`]).
1372fn probe_object(ctx: &Ctx, t: &SlotTerm, base: &Row) -> Option<Option<u32>> {
1373    let dict = ctx.rete.dictionary();
1374    match t {
1375        SlotTerm::Node(n) => dict.node_as_object_id(*n).map(Some),
1376        SlotTerm::Pred(_) => None,
1377        SlotTerm::Var(i) => match &base[*i] {
1378            None => Some(None),
1379            Some(Val::Id(v)) if *v >= 0 => dict.node_as_object_id(*v as u32).map(Some),
1380            Some(_) => None,
1381        },
1382    }
1383}
1384
1385/// Index-scan constant for a predicate position (see [`probe_subject`]).
1386fn probe_predicate(ctx: &Ctx, t: &SlotTerm, base: &Row) -> Option<Option<u32>> {
1387    let dict = ctx.rete.dictionary();
1388    match t {
1389        SlotTerm::Pred(p) => Some(Some(*p)),
1390        SlotTerm::Node(_) => None,
1391        SlotTerm::Var(i) => match &base[*i] {
1392            None => Some(None),
1393            Some(Val::Id(v)) if *v < 0 => Some(Some((-v - 1) as u32)),
1394            // Canonicalized to a node id — the term may still be a predicate.
1395            Some(Val::Id(v)) => ctx
1396                .resolver
1397                .term(*v)
1398                .and_then(|t| dict.predicate_id(&t))
1399                .map(Some),
1400            Some(Val::Str(_)) => None,
1401        },
1402    }
1403}
1404
1405/// Probe one pattern with a partially-bound row: every bound variable becomes
1406/// an index-scan constant, and each matching triple extends a clone of the row
1407/// (repeated unbound variables stay consistent).
1408fn probe_rows<'q>(
1409    ctx: &'q Ctx<'q>,
1410    index: &'q GraphIndex,
1411    t: (SlotTerm, SlotTerm, SlotTerm),
1412    base: Row,
1413) -> Box<dyn Iterator<Item = Row> + 'q> {
1414    let (Some(sid), Some(pid), Some(oid)) = (
1415        probe_subject(ctx, &t.0, &base),
1416        probe_predicate(ctx, &t.1, &base),
1417        probe_object(ctx, &t.2, &base),
1418    ) else {
1419        return Box::new(std::iter::empty());
1420    };
1421    let dict = ctx.rete.dictionary();
1422    Box::new(
1423        index
1424            .scan_iter((sid, pid, oid))
1425            .filter_map(move |(s_id, p_id, o_id)| {
1426                let s_val = dict.subject_node(s_id) as i64;
1427                let p_val = ctx.resolver.canon_id(pred_tag(p_id));
1428                let o_val = dict.object_node(o_id) as i64;
1429                let mut row = base.clone();
1430                for (term, val) in [(&t.0, s_val), (&t.1, p_val), (&t.2, o_val)] {
1431                    if let SlotTerm::Var(i) = term {
1432                        match row[*i] {
1433                            Some(Val::Id(existing)) if existing != val => return None,
1434                            Some(Val::Id(_)) => {}
1435                            Some(Val::Str(_)) => return None,
1436                            None => row[*i] = Some(Val::Id(val)),
1437                        }
1438                    }
1439                }
1440                Some(row)
1441            }),
1442    )
1443}
1444
1445/// A lowered, probe-ordered BGP, reusable across many seed rows.
1446pub(crate) struct ProbePlan {
1447    pats: Vec<(SlotTerm, SlotTerm, SlotTerm)>,
1448}
1449
1450impl ProbePlan {
1451    /// Lower and order `patterns` for probing from rows that bind (at least)
1452    /// the slots in `seed_mask`. `None` = a constant term is unknown, making
1453    /// the BGP unsatisfiable for every seed.
1454    pub(crate) fn new(
1455        ctx: &Ctx,
1456        index: &GraphIndex,
1457        patterns: &[TriplePattern],
1458        seed_mask: &[bool],
1459    ) -> Option<Self> {
1460        let lowered = lower(patterns, ctx)?;
1461        let seed: std::collections::HashSet<usize> = seed_mask
1462            .iter()
1463            .enumerate()
1464            .filter_map(|(i, b)| b.then_some(i))
1465            .collect();
1466        let order = selectivity_order_seeded(ctx, index, &lowered, &seed);
1467        Some(ProbePlan {
1468            pats: order.into_iter().map(|i| lowered[i]).collect(),
1469        })
1470    }
1471}
1472
1473/// Depth-first index-nested-loop join over a [`ProbePlan`], starting from a
1474/// seed row. Fully lazy: pulling k rows probes O(k · patterns) index groups.
1475pub(crate) struct ProbeJoin<'q> {
1476    ctx: &'q Ctx<'q>,
1477    index: &'q GraphIndex,
1478    pats: Vec<(SlotTerm, SlotTerm, SlotTerm)>,
1479    stack: Vec<Box<dyn Iterator<Item = Row> + 'q>>,
1480}
1481
1482impl<'q> ProbeJoin<'q> {
1483    /// Probe a whole BGP from scratch (the seed is the all-unbound row).
1484    /// `None` = unsatisfiable.
1485    pub(crate) fn new(
1486        ctx: &'q Ctx<'q>,
1487        index: &'q GraphIndex,
1488        patterns: &[TriplePattern],
1489    ) -> Option<Self> {
1490        let plan = ProbePlan::new(ctx, index, patterns, &vec![false; ctx.slots.len()])?;
1491        Some(Self::from_plan(ctx, index, &plan, ctx.slots.empty_row()))
1492    }
1493
1494    /// Probe a pre-lowered plan from one seed row.
1495    pub(crate) fn from_plan(
1496        ctx: &'q Ctx<'q>,
1497        index: &'q GraphIndex,
1498        plan: &ProbePlan,
1499        seed: Row,
1500    ) -> Self {
1501        let pats = plan.pats.clone();
1502        let first = probe_rows(ctx, index, pats[0], seed);
1503        ProbeJoin {
1504            ctx,
1505            index,
1506            pats,
1507            stack: vec![first],
1508        }
1509    }
1510}
1511
1512impl Iterator for ProbeJoin<'_> {
1513    type Item = Row;
1514
1515    fn next(&mut self) -> Option<Row> {
1516        loop {
1517            let depth = self.stack.len();
1518            match self.stack.last_mut()?.next() {
1519                Some(row) => {
1520                    if depth == self.pats.len() {
1521                        return Some(row);
1522                    }
1523                    let it = probe_rows(self.ctx, self.index, self.pats[depth], row);
1524                    self.stack.push(it);
1525                }
1526                None => {
1527                    self.stack.pop();
1528                    if self.stack.is_empty() {
1529                        return None;
1530                    }
1531                }
1532            }
1533        }
1534    }
1535}
1536
1537/// Hash-join two BGP relations on the slots they share. Every left row binds
1538/// exactly the slots accumulated so far and every right row binds the current
1539/// pattern's slots, so the shared set is uniform across rows.
1540fn hash_join(
1541    left: Vec<Row>,
1542    left_bound: &[usize],
1543    right: Vec<Row>,
1544    right_slots: &[usize],
1545) -> Vec<Row> {
1546    // Fast path: the all-unbound seed row joins to `right` unchanged.
1547    if left.len() == 1 && left_bound.is_empty() {
1548        return right;
1549    }
1550    if left.is_empty() || right.is_empty() {
1551        return Vec::new();
1552    }
1553    let shared: Vec<usize> = right_slots
1554        .iter()
1555        .copied()
1556        .filter(|s| left_bound.contains(s))
1557        .collect();
1558    let fill = |l: &Row, r: &Row| -> Row {
1559        let mut out = l.clone();
1560        for &s in right_slots {
1561            out[s] = r[s].clone();
1562        }
1563        out
1564    };
1565    if shared.is_empty() {
1566        // No shared variable: Cartesian product. In a connected BGP this only
1567        // arises for a fully-ground pattern (deduped index ⇒ one matching row),
1568        // so it acts as an existence filter rather than multiplying rows.
1569        let mut out = Vec::with_capacity(left.len() * right.len());
1570        for l in &left {
1571            for r in &right {
1572                out.push(fill(l, r));
1573            }
1574        }
1575        return out;
1576    }
1577    let key_of = |b: &Row| -> Vec<Val> { shared.iter().map(|&s| b[s].clone().unwrap()).collect() };
1578    // A hash join is symmetric on the shared key, so build the **smaller** side's
1579    // hash table and probe with the larger — same result set, fewer inserts and a
1580    // smaller table. (The old code always built `right`; after cardinality-based
1581    // ordering the accumulating `left` is usually the small selective side, so
1582    // building it instead is the common win.) Whichever side is built, the output
1583    // row carries every bound slot of both.
1584    let mut out = Vec::new();
1585    if right.len() <= left.len() {
1586        let mut buckets: HashMap<Vec<Val>, Vec<Row>> = HashMap::new();
1587        for r in right {
1588            buckets.entry(key_of(&r)).or_default().push(r);
1589        }
1590        for l in &left {
1591            if let Some(rs) = buckets.get(&key_of(l)) {
1592                for r in rs {
1593                    out.push(fill(l, r));
1594                }
1595            }
1596        }
1597    } else {
1598        // Build the left side; fill the left's own slots into each probed right.
1599        let mut buckets: HashMap<Vec<Val>, Vec<Row>> = HashMap::new();
1600        for l in left {
1601            buckets.entry(key_of(&l)).or_default().push(l);
1602        }
1603        for r in &right {
1604            if let Some(ls) = buckets.get(&key_of(r)) {
1605                for l in ls {
1606                    let mut row = r.clone();
1607                    for &s in left_bound {
1608                        if let Some(v) = &l[s] {
1609                            row[s] = Some(v.clone());
1610                        }
1611                    }
1612                    out.push(row);
1613                }
1614            }
1615        }
1616    }
1617    out
1618}
1619
1620// Constant-only index constraints. A variable scans as a wildcard (`Some(None)`)
1621// — it is resolved later by the hash join, not pushed into the scan. The outer
1622// `None` means "unsatisfiable" (a constant in an impossible role, or unknown to
1623// the dictionary), which empties the whole BGP. Inner `None` = "wildcard".
1624fn const_subject(t: &SlotTerm, d: &crate::Dictionary) -> Option<Option<u32>> {
1625    match t {
1626        SlotTerm::Node(n) => d.node_as_subject_id(*n).map(Some),
1627        SlotTerm::Pred(_) => None, // a predicate term can't be a subject
1628        SlotTerm::Var(_) => Some(None),
1629    }
1630}
1631
1632fn const_object(t: &SlotTerm, d: &crate::Dictionary) -> Option<Option<u32>> {
1633    match t {
1634        SlotTerm::Node(n) => d.node_as_object_id(*n).map(Some),
1635        SlotTerm::Pred(_) => None,
1636        SlotTerm::Var(_) => Some(None),
1637    }
1638}
1639
1640fn const_predicate(t: &SlotTerm) -> Option<Option<u32>> {
1641    match t {
1642        SlotTerm::Pred(p) => Some(Some(*p)),
1643        SlotTerm::Node(_) => None, // a node term can't be a predicate
1644        SlotTerm::Var(_) => Some(None),
1645    }
1646}
1647
1648#[cfg(test)]
1649mod tests {
1650    use super::*;
1651    use crate::dictionary::DictionaryBuilder;
1652    use crate::file::write_file;
1653    use crate::index::GraphIndexBuilder;
1654
1655    fn rete_from(triples: &[(&str, &str, &str)]) -> Vec<u8> {
1656        let mut db = DictionaryBuilder::new();
1657        for (s, p, o) in triples {
1658            db.observe(s, p, o);
1659        }
1660        let dict = db.build();
1661        let mut ib = GraphIndexBuilder::new();
1662        for (s, p, o) in triples {
1663            ib.push(dict.encode(s, p, o).unwrap());
1664        }
1665        write_file(&dict, &ib.build(), false, &[], 0)
1666    }
1667
1668    fn pat(s: &str, p: &str, o: &str) -> TriplePattern {
1669        TriplePattern {
1670            s: PatternTerm::parse(s),
1671            p: PatternTerm::parse(p),
1672            o: PatternTerm::parse(o),
1673        }
1674    }
1675
1676    /// Every join path the planner can pick — materialized prefix, probed last
1677    /// pattern, streamed seed with hashed or probed later patterns, streaming
1678    /// merge join — returns the same solution multiset. The thresholds are
1679    /// shrunk so a 400-triple graph takes each path.
1680    #[test]
1681    fn every_join_path_returns_the_same_solutions() {
1682        let mut owned: Vec<(String, String, String)> = Vec::new();
1683        let mut x: u64 = 7;
1684        let mut next = |m: u64| {
1685            x = x
1686                .wrapping_mul(6364136223846793005)
1687                .wrapping_add(1442695040888963407);
1688            (x >> 33) % m
1689        };
1690        for i in 0..60 {
1691            let s = format!("s{i}");
1692            owned.push((s.clone(), "a".into(), format!("C{}", next(3))));
1693            for p in 0..4 {
1694                for _ in 0..next(3) {
1695                    owned.push((s.clone(), format!("p{p}"), format!("s{}", next(60))));
1696                }
1697            }
1698        }
1699        let triples: Vec<(&str, &str, &str)> = owned
1700            .iter()
1701            .map(|(s, p, o)| (s.as_str(), p.as_str(), o.as_str()))
1702            .collect();
1703        let bytes = rete_from(&triples);
1704        let rete = Rete::open(&bytes).unwrap();
1705        let queries: Vec<Vec<TriplePattern>> = vec![
1706            vec![pat("?s", "p0", "?a"), pat("?s", "p1", "?b")],
1707            vec![pat("?s", "a", "C1"), pat("?s", "p0", "?a")],
1708            vec![pat("?s", "p0", "?a"), pat("?a", "p2", "?b")],
1709            vec![pat("s5", "p0", "?a"), pat("?a", "?p", "?o")],
1710            vec![pat("?s", "p0", "?x"), pat("?s", "p1", "?x")],
1711            vec![
1712                pat("?s", "p3", "?o"),
1713                pat("?o", "a", "C0"),
1714                pat("?s", "p1", "?z"),
1715            ],
1716            vec![
1717                pat("?s", "p0", "?a"),
1718                pat("?s", "p1", "?b"),
1719                pat("?b", "p2", "?c"),
1720            ],
1721        ];
1722        let run = |limits: Option<PlanLimits>| -> Vec<Vec<Vec<(String, String)>>> {
1723            TEST_LIMITS.with(|c| c.set(limits));
1724            let out = queries
1725                .iter()
1726                .map(|q| {
1727                    let mut slots = crate::row::Slots::new();
1728                    collect_pattern_slots(q, &mut slots);
1729                    let ctx = Ctx::new(&rete, slots);
1730                    let mut rows: Vec<Vec<(String, String)>> =
1731                        BgpSolutions::new(&ctx, rete.default_index(), q)
1732                            .map(|r| row_to_binding(&ctx, &r).into_iter().collect())
1733                            .collect();
1734                    rows.sort();
1735                    rows
1736                })
1737                .collect();
1738            TEST_LIMITS.with(|c| c.set(None));
1739            out
1740        };
1741        let reference = run(None);
1742        assert!(reference.iter().all(|r| !r.is_empty()), "{reference:?}");
1743        for (name, l) in [
1744            (
1745                "probe everything",
1746                PlanLimits {
1747                    probe_floor: 0.0,
1748                    stream_seed: f64::MAX,
1749                    hash_build: f64::MAX,
1750                },
1751            ),
1752            (
1753                "stream + hash",
1754                PlanLimits {
1755                    probe_floor: f64::MAX,
1756                    stream_seed: 0.0,
1757                    hash_build: f64::MAX,
1758                },
1759            ),
1760            (
1761                "stream + probe",
1762                PlanLimits {
1763                    probe_floor: f64::MAX,
1764                    stream_seed: 0.0,
1765                    hash_build: 0.0,
1766                },
1767            ),
1768            (
1769                "stream, all paths",
1770                PlanLimits {
1771                    probe_floor: 0.0,
1772                    stream_seed: 0.0,
1773                    hash_build: 30.0,
1774                },
1775            ),
1776        ] {
1777            assert_eq!(run(Some(l)), reference, "{name}");
1778        }
1779    }
1780
1781    #[test]
1782    fn single_pattern_binds_variable() {
1783        let bytes = rete_from(&[("Alice", "knows", "Bob"), ("Bob", "knows", "Carol")]);
1784        let rete = Rete::open(&bytes).unwrap();
1785        let sols = eval_bgp(&rete, &[pat("Alice", "knows", "?y")]);
1786        assert_eq!(sols.len(), 1);
1787        assert_eq!(sols[0]["y"], "Bob");
1788    }
1789
1790    #[test]
1791    fn two_hop_join_on_shared_variable() {
1792        // Alice -> Bob -> Carol, plus a dead-end branch.
1793        let bytes = rete_from(&[
1794            ("Alice", "knows", "Bob"),
1795            ("Bob", "knows", "Carol"),
1796            ("Carol", "knows", "Dave"),
1797            ("Alice", "knows", "Eve"), // Eve knows no one -> no 2-hop
1798        ]);
1799        let rete = Rete::open(&bytes).unwrap();
1800        let sols = eval_bgp(&rete, &[pat("?x", "knows", "?y"), pat("?y", "knows", "?z")]);
1801        // Alice-Bob-Carol, Bob-Carol-Dave.
1802        let mut got: Vec<_> = sols
1803            .iter()
1804            .map(|b| (b["x"].clone(), b["y"].clone(), b["z"].clone()))
1805            .collect();
1806        got.sort();
1807        assert_eq!(
1808            got,
1809            vec![
1810                ("Alice".into(), "Bob".into(), "Carol".into()),
1811                ("Bob".into(), "Carol".into(), "Dave".into()),
1812            ]
1813        );
1814    }
1815
1816    #[test]
1817    fn repeated_variable_within_pattern() {
1818        // A mutual/self relation: only Bob knows himself.
1819        let bytes = rete_from(&[("Alice", "knows", "Bob"), ("Bob", "knows", "Bob")]);
1820        let rete = Rete::open(&bytes).unwrap();
1821        let sols = eval_bgp(&rete, &[pat("?x", "knows", "?x")]);
1822        assert_eq!(sols.len(), 1);
1823        assert_eq!(sols[0]["x"], "Bob");
1824    }
1825
1826    #[test]
1827    fn no_solutions_yields_empty() {
1828        let bytes = rete_from(&[("Alice", "knows", "Bob")]);
1829        let rete = Rete::open(&bytes).unwrap();
1830        assert!(eval_bgp(&rete, &[pat("Alice", "likes", "?y")]).is_empty());
1831    }
1832
1833    /// A LUBM-Q7-shaped snowflake: a popular class enumeration (`?x a Student`)
1834    /// plus a selective adjacency seed (`<Prof> teacherOf ?y`). Exercises both the
1835    /// **class-enum seed deprioritization** (the join must not start from the huge
1836    /// `a Student` extent) and the **probe-the-tail hybrid** (the type checks are
1837    /// applied late, per row). The answer must equal a brute-force computation
1838    /// regardless of how the join is ordered/probed.
1839    #[test]
1840    fn snowflake_type_and_adjacency_matches_reference() {
1841        const TYPE: &str = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>";
1842        let mut triples: Vec<(String, String, String)> = Vec::new();
1843        // 60 students, each a Student and taking 2 courses (c{i%5}, c{(i+1)%5}).
1844        for i in 0..60 {
1845            let s = format!("S{i}");
1846            triples.push((s.clone(), TYPE.into(), "Student".into()));
1847            triples.push((s.clone(), "takesCourse".into(), format!("c{}", i % 5)));
1848            triples.push((s, "takesCourse".into(), format!("c{}", (i + 1) % 5)));
1849        }
1850        // 5 courses; the professor teaches only c0 and c2.
1851        for j in 0..5 {
1852            triples.push((format!("c{j}"), TYPE.into(), "Course".into()));
1853        }
1854        triples.push(("Prof".into(), "teacherOf".into(), "c0".into()));
1855        triples.push(("Prof".into(), "teacherOf".into(), "c2".into()));
1856
1857        let refs: Vec<(&str, &str, &str)> = triples
1858            .iter()
1859            .map(|(s, p, o)| (s.as_str(), p.as_str(), o.as_str()))
1860            .collect();
1861        let bytes = rete_from(&refs);
1862        let rete = Rete::open(&bytes).unwrap();
1863
1864        let got: std::collections::BTreeSet<(String, String)> = eval_bgp(
1865            &rete,
1866            &[
1867                pat("?x", TYPE, "Student"),
1868                pat("?y", TYPE, "Course"),
1869                pat("?x", "takesCourse", "?y"),
1870                pat("Prof", "teacherOf", "?y"),
1871            ],
1872        )
1873        .into_iter()
1874        .map(|b| (b["x"].clone(), b["y"].clone()))
1875        .collect();
1876
1877        // Brute force: students taking a course the professor teaches (c0 or c2).
1878        let taught = ["c0", "c2"];
1879        let mut want = std::collections::BTreeSet::new();
1880        for i in 0..60 {
1881            for c in [i % 5, (i + 1) % 5] {
1882                let course = format!("c{c}");
1883                if taught.contains(&course.as_str()) {
1884                    want.insert((format!("S{i}"), course));
1885                }
1886            }
1887        }
1888        assert_eq!(got, want, "snowflake join must match the brute-force set");
1889        assert!(!want.is_empty(), "sanity: the reference set is non-empty");
1890    }
1891
1892    /// A subject-subject join (`?x a ?av . ?x b ?bv`) is the same-role shape the
1893    /// merge join handles: both patterns sort on the subject column. With multiple
1894    /// `a` and `b` values per subject it must emit the full cross product per
1895    /// subject (equal-key run handling) and drop subjects missing either side.
1896    #[test]
1897    fn merge_join_subject_star_cross_product() {
1898        let bytes = rete_from(&[
1899            ("x", "a", "a1"),
1900            ("x", "a", "a2"),
1901            ("x", "b", "b1"),
1902            ("x", "b", "b2"),
1903            ("y", "a", "a3"),
1904            ("y", "b", "b3"),
1905            ("z", "a", "a4"), // z has no `b` → contributes no join row
1906        ]);
1907        let rete = Rete::open(&bytes).unwrap();
1908        let mut got: Vec<(String, String, String)> =
1909            eval_bgp(&rete, &[pat("?x", "a", "?av"), pat("?x", "b", "?bv")])
1910                .iter()
1911                .map(|m| (m["x"].clone(), m["av"].clone(), m["bv"].clone()))
1912                .collect();
1913        got.sort();
1914        let mut want: Vec<(String, String, String)> = [
1915            ("x", "a1", "b1"),
1916            ("x", "a1", "b2"),
1917            ("x", "a2", "b1"),
1918            ("x", "a2", "b2"),
1919            ("y", "a3", "b3"),
1920        ]
1921        .into_iter()
1922        .map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string()))
1923        .collect();
1924        want.sort();
1925        assert_eq!(got, want, "same-role merge must equal the brute-force join");
1926    }
1927}