Skip to main content

shifty_opt/sparql_native/
plan.rs

1//! Stage 3 native physical plan and lowering.
2//!
3//! `lower_query` translates the BGP subset of a (statically substituted)
4//! Spargebra query into a small physical operator tree (`NativeQueryPlan`) that
5//! the engine's native executor runs directly over the `FrozenIndexedDataset`.
6//! Anything outside the subset returns `Err(reason)` so the caller falls back
7//! to Spareval — the routing decision is made here, once, at planning time
8//! (`docs/05-sparql-execution.md §46`, §263): a query is never half-evaluated by
9//! both engines.
10//!
11//! ## Native subset
12//!
13//! - `SELECT` / `ASK`;
14//! - basic graph patterns and fixed `GRAPH <iri> {…}` blocks;
15//! - `JOIN`, `UNION`, `PROJECT`, `DISTINCT`;
16//! - `FILTER` over the provably-safe boolean subset (`BOUND`, `&&`, `||`, `!`,
17//!   `sameTerm`, safe equality, `STR`, `STRSTARTS`);
18//! - property paths and correlated `EXISTS` / `NOT EXISTS`;
19//! - `BIND` of supported expressions.
20//!
21//! Ordered comparisons, `IN`, arithmetic, most function calls, aggregates,
22//! `OPTIONAL`, `MINUS`, `VALUES`, `ORDER BY`, `LIMIT`, and variable graph names
23//! all fall back. New constructs are lowered only when they can be evaluated
24//! identically to the Spareval oracle.
25//!
26//! ## Execution model
27//!
28//! Operators form a left-deep pipeline rooted at `InputFocus`, which emits one
29//! seed solution per focus node (binding `$this`). Each `Scan` extends its input
30//! solutions by indexed-nested-loop matching against the dataset; `$this` is a
31//! batched input column rather than a substituted constant (doc §88), so one
32//! plan evaluates many focus nodes together. `Join(A, B)` is lowered by threading
33//! `A`'s pipeline as the input of `B` (sound because BGP join only *adds* bound
34//! variables to the evaluation context).
35
36use shifty_algebra::Path;
37use spargebra::Query;
38use spargebra::algebra::{Expression, Function, GraphPattern, PropertyPathExpression};
39use spargebra::term::{NamedNode, NamedNodePattern, Term, TermPattern, TriplePattern};
40use std::collections::{HashMap, HashSet};
41
42/// Index into [`NativeQueryPlan::nodes`].
43pub type OpId = u32;
44/// Index into [`NativeQueryPlan::var_names`].
45pub type VarId = u32;
46
47/// An operand in a triple-scan position: a bound constant or a (possibly free)
48/// variable. Query blank nodes are treated as fresh variables.
49#[derive(Debug, Clone)]
50pub enum ScanTerm {
51    Var(VarId),
52    Const(Term),
53}
54
55/// Which graph a `Scan` reads. Variable graph names are unsupported (fallback),
56/// so only the default graph and fixed named graphs appear here.
57#[derive(Debug, Clone)]
58pub enum GraphScan {
59    Default,
60    Named(NamedNode),
61}
62
63/// A single triple pattern to match against the dataset.
64#[derive(Debug, Clone)]
65pub struct TripleScan {
66    pub subject: ScanTerm,
67    pub predicate: ScanTerm,
68    pub object: ScanTerm,
69    pub graph: GraphScan,
70}
71
72/// The arbitrary-length repetition wrapping a [`PathScan`]'s step. These three
73/// SPARQL operators use *distinct* (set) semantics, unlike the translatable
74/// connectives (sequence, alternative, inverse, predicate) which are lowered to
75/// `Scan`/`Join`/`Union` and keep multiset semantics.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
77pub enum ClosureKind {
78    /// `p*` — reflexive-transitive closure (includes the start node).
79    Star,
80    /// `p+` — transitive closure (one or more steps).
81    Plus,
82    /// `p?` — zero or one step.
83    Opt,
84}
85
86/// An arbitrary-length property-path match. `step` is the (possibly compound)
87/// inner path evaluated set-at-a-time per node; `kind` is the repetition. Both
88/// endpoint binding modes are supported at execution: a bound endpoint drives a
89/// forward (subject bound) or reverse (object bound) closure, both bound is a
90/// membership test, and both free is a relation scan over the node domain.
91#[derive(Debug, Clone)]
92pub struct PathScan {
93    pub subject: ScanTerm,
94    pub object: ScanTerm,
95    pub step: Path,
96    pub kind: ClosureKind,
97    pub graph: GraphScan,
98}
99
100/// A compiled expression in the safe stage-3 subset. Evaluated in boolean
101/// (effective-boolean-value) context by `Filter`, and in value context by
102/// `Extend`. Every node here has semantics that match Spareval exactly for all
103/// inputs — that is the admission criterion for lowering an expression natively.
104#[derive(Debug, Clone)]
105pub enum ExprPlan {
106    Var(VarId),
107    Const(Term),
108    Bound(VarId),
109    Not(Box<ExprPlan>),
110    And(Box<ExprPlan>, Box<ExprPlan>),
111    Or(Box<ExprPlan>, Box<ExprPlan>),
112    SameTerm(Box<ExprPlan>, Box<ExprPlan>),
113    /// SPARQL `STR`, producing the lexical form of an IRI or literal.
114    Str(Box<ExprPlan>),
115    /// SPARQL `STRSTARTS` over argument-compatible strings.
116    StrStarts(Box<ExprPlan>, Box<ExprPlan>),
117    /// SPARQL value equality (`=`). Uses term-id identity as the fast path.
118    /// Returns `None` (type error) for cross-type numeric comparisons rather
119    /// than implementing full numeric promotion — those queries fall through
120    /// the FILTER just as SPARQL type errors do.
121    Equal(Box<ExprPlan>, Box<ExprPlan>),
122    /// Correlated `EXISTS { … }`: true iff the sub-plan rooted at this `OpId`,
123    /// seeded with the current solution, yields at least one row. `NOT EXISTS`
124    /// is `Not(Exists(..))`. The sub-plan lives in the same `nodes` arena.
125    Exists(OpId),
126}
127
128/// A physical operator. Children are referenced by [`OpId`] into the owning
129/// plan's `nodes` arena.
130#[derive(Debug, Clone)]
131pub enum NativeOp {
132    /// Leaf: emit one solution per focus node, binding `$this`.
133    InputFocus,
134    /// Extend input solutions by indexed-nested-loop matching `pattern`.
135    Scan { input: OpId, pattern: TripleScan },
136    /// Extend input solutions by an arbitrary-length property path (`*`/`+`/`?`).
137    PathScan { input: OpId, scan: PathScan },
138    /// Union of two sub-plans evaluated over the same input.
139    Union { left: OpId, right: OpId },
140    /// Keep input solutions whose `expr` has effective boolean value `true`.
141    Filter { input: OpId, expr: ExprPlan },
142    /// Bind `var` to `expr` (left unbound if `expr` errors), keeping all rows.
143    Extend {
144        input: OpId,
145        var: VarId,
146        expr: ExprPlan,
147    },
148    /// Restrict each solution's bindings to `vars` (the focus tag is implicit).
149    Project { input: OpId, vars: Vec<VarId> },
150    /// Deduplicate solutions (per focus node).
151    Distinct { input: OpId },
152}
153
154/// Whether the query yields solution rows (`Select`) or a single boolean
155/// (`Ask` — any solution means the constraint matched).
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum QueryForm {
158    Select,
159    Ask,
160}
161
162/// Dataset statistics passed from `FrozenIndexedDataset` to guide BGP
163/// scan reordering at plan-compilation time. Keyed by RDF `Term` so the
164/// planner can look up constants without touching the frozen dictionary.
165#[derive(Debug, Clone, Default)]
166pub struct PlanStats {
167    pub total_triples: u64,
168    pub distinct_subjects: u64,
169    pub distinct_objects: u64,
170    pub distinct_predicates: u64,
171    /// Triples per predicate IRI.
172    pub predicate_cardinality: HashMap<Term, u64>,
173}
174
175/// A lowered native query plan over a `FrozenIndexedDataset`.
176#[derive(Debug, Clone)]
177pub struct NativeQueryPlan {
178    pub nodes: Vec<NativeOp>,
179    pub root: OpId,
180    pub form: QueryForm,
181    /// `VarId` → variable name (used to read out `?value` / `?path` results).
182    pub var_names: Vec<String>,
183    /// The `VarId` of `$this`, bound per focus node by `InputFocus`.
184    pub focus_var: VarId,
185}
186
187impl NativeQueryPlan {
188    /// Look up a variable's id by name, if it occurs in the plan.
189    pub fn var_id(&self, name: &str) -> Option<VarId> {
190        self.var_names
191            .iter()
192            .position(|n| n == name)
193            .map(|i| i as VarId)
194    }
195}
196
197// ── Lowering ─────────────────────────────────────────────────────────────────
198
199/// The name of the SHACL focus-node variable, bound by `InputFocus`.
200const FOCUS_VAR: &str = "this";
201
202struct Builder {
203    nodes: Vec<NativeOp>,
204    var_ids: HashMap<String, VarId>,
205    var_names: Vec<String>,
206    fresh: u32,
207}
208
209impl Builder {
210    fn new() -> Self {
211        Self {
212            nodes: Vec::new(),
213            var_ids: HashMap::new(),
214            var_names: Vec::new(),
215            fresh: 0,
216        }
217    }
218
219    fn var(&mut self, name: &str) -> VarId {
220        if let Some(&id) = self.var_ids.get(name) {
221            return id;
222        }
223        let id = self.var_names.len() as VarId;
224        self.var_names.push(name.to_string());
225        self.var_ids.insert(name.to_string(), id);
226        id
227    }
228
229    /// A fresh internal variable, used as the join midpoint when decomposing a
230    /// path sequence. The name can't collide with a query variable (`?` is not a
231    /// legal SPARQL variable character).
232    fn fresh_var(&mut self) -> VarId {
233        let name = format!("?path{}", self.fresh);
234        self.fresh += 1;
235        self.var(&name)
236    }
237
238    fn push(&mut self, op: NativeOp) -> OpId {
239        let id = self.nodes.len() as OpId;
240        self.nodes.push(op);
241        id
242    }
243
244    fn focus_var(&self) -> VarId {
245        *self
246            .var_ids
247            .get(FOCUS_VAR)
248            .expect("focus var registered first")
249    }
250}
251
252/// Lower a statically substituted query into a native plan, or return a fallback
253/// reason. `$this` must remain a free variable in `query` (it is bound per focus
254/// by the executor); all other SHACL parameters should already be substituted.
255pub fn lower_query(query: &Query) -> Result<NativeQueryPlan, String> {
256    lower_query_with_stats(query, None)
257}
258
259/// Like [`lower_query`] but with optional dataset statistics. When `stats` is
260/// `Some`, BGP triple patterns within each `GraphPattern::Bgp` block are
261/// reordered greedily by estimated output cardinality before lowering: the
262/// most selective scan (smallest expected row count given currently bound
263/// variables) is placed first. The plan's semantics are unchanged — BGP join
264/// is commutative — but the left-deep pipeline evaluates fewer intermediate
265/// rows.
266pub fn lower_query_with_stats(
267    query: &Query,
268    stats: Option<&PlanStats>,
269) -> Result<NativeQueryPlan, String> {
270    let (pattern, form) = match query {
271        Query::Select { pattern, .. } => (pattern, QueryForm::Select),
272        Query::Ask { pattern, .. } => (pattern, QueryForm::Ask),
273        Query::Construct { .. } => return Err("CONSTRUCT query".into()),
274        Query::Describe { .. } => return Err("DESCRIBE query".into()),
275    };
276
277    let mut b = Builder::new();
278    let focus_var = b.var(FOCUS_VAR);
279    let input = b.push(NativeOp::InputFocus);
280    let root = lower_pattern(&mut b, pattern, input, &GraphScan::Default, stats)?;
281
282    Ok(NativeQueryPlan {
283        nodes: b.nodes,
284        root,
285        form,
286        var_names: b.var_names,
287        focus_var,
288    })
289}
290
291/// Lower a graph pattern, threading `input` (the pipeline producing the
292/// already-bound context) and the current `graph` scope through it.
293fn lower_pattern(
294    b: &mut Builder,
295    pattern: &GraphPattern,
296    input: OpId,
297    graph: &GraphScan,
298    stats: Option<&PlanStats>,
299) -> Result<OpId, String> {
300    match pattern {
301        GraphPattern::Bgp { patterns } => {
302            let mut scans: Vec<TripleScan> = patterns
303                .iter()
304                .map(|tp| lower_triple(b, tp, graph))
305                .collect::<Result<_, _>>()?;
306            if let Some(s) = stats {
307                reorder_bgp(&mut scans, b.focus_var(), s);
308            }
309            let mut current = input;
310            for scan in scans {
311                current = b.push(NativeOp::Scan {
312                    input: current,
313                    pattern: scan,
314                });
315            }
316            Ok(current)
317        }
318        GraphPattern::Join { left, right } => {
319            // Flatten the entire join tree into BGP/Path leaves and apply
320            // greedy cost-based ordering across all of them at once.
321            // Join(Join(A, B), C) is treated as three independent leaves, not
322            // two nested binary decisions — this generalises the two-arm case.
323            if let Some(s) = stats {
324                let mut leaves = Vec::new();
325                if collect_join_leaves(pattern, &mut leaves) && leaves.len() >= 2 {
326                    return lower_join_leaves(b, leaves, input, graph, s);
327                }
328            }
329            let l = lower_pattern(b, left, input, graph, stats)?;
330            lower_pattern(b, right, l, graph, stats)
331        }
332        GraphPattern::Union { left, right } => {
333            let l = lower_pattern(b, left, input, graph, stats)?;
334            let r = lower_pattern(b, right, input, graph, stats)?;
335            Ok(b.push(NativeOp::Union { left: l, right: r }))
336        }
337        GraphPattern::Filter { expr, inner } => {
338            let i = lower_pattern(b, inner, input, graph, stats)?;
339            let e = lower_expr(b, expr, graph, stats)?;
340            Ok(b.push(NativeOp::Filter { input: i, expr: e }))
341        }
342        GraphPattern::Extend {
343            inner,
344            variable,
345            expression,
346        } => {
347            let i = lower_pattern(b, inner, input, graph, stats)?;
348            let e = lower_expr(b, expression, graph, stats)?;
349            let var = b.var(variable.as_str());
350            Ok(b.push(NativeOp::Extend {
351                input: i,
352                var,
353                expr: e,
354            }))
355        }
356        GraphPattern::Project { inner, variables } => {
357            let i = lower_pattern(b, inner, input, graph, stats)?;
358            let vars = variables.iter().map(|v| b.var(v.as_str())).collect();
359            Ok(b.push(NativeOp::Project { input: i, vars }))
360        }
361        GraphPattern::Distinct { inner } => {
362            let i = lower_pattern(b, inner, input, graph, stats)?;
363            Ok(b.push(NativeOp::Distinct { input: i }))
364        }
365        GraphPattern::Graph { name, inner } => match name {
366            NamedNodePattern::NamedNode(nn) => {
367                lower_pattern(b, inner, input, &GraphScan::Named(nn.clone()), stats)
368            }
369            NamedNodePattern::Variable(_) => Err("variable GRAPH name".into()),
370        },
371        GraphPattern::Path {
372            subject,
373            path,
374            object,
375        } => {
376            let s = lower_term_pattern(b, subject)?;
377            let o = lower_term_pattern(b, object)?;
378            lower_path(b, s, path, o, input, graph)
379        }
380        GraphPattern::Reduced { .. } => Err("REDUCED".into()),
381        GraphPattern::Values { .. } => Err("inline VALUES".into()),
382        GraphPattern::OrderBy { .. } => Err("ORDER BY".into()),
383        GraphPattern::Slice { .. } => Err("LIMIT/OFFSET".into()),
384        GraphPattern::Group { .. } => Err("aggregates (GROUP BY)".into()),
385        GraphPattern::Service { .. } => Err("SERVICE".into()),
386        GraphPattern::LeftJoin { .. } => Err("OPTIONAL".into()),
387        GraphPattern::Minus { .. } => Err("MINUS".into()),
388        GraphPattern::Lateral { .. } => Err("LATERAL".into()),
389    }
390}
391
392// ── BGP scan reordering ───────────────────────────────────────────────────────
393
394/// Reorder `scans` in-place using a greedy minimum-cost algorithm.
395///
396/// Starting from the set of variables bound by `InputFocus` (just `?this`),
397/// at each step we pick the scan with the lowest estimated output cardinality
398/// given the variables bound so far, then add that scan's output variables to
399/// the bound set. BGP join is commutative so the final result set is unchanged.
400fn reorder_bgp(scans: &mut Vec<TripleScan>, focus_var: VarId, stats: &PlanStats) {
401    if scans.len() < 2 {
402        return;
403    }
404    let mut bound: HashSet<VarId> = std::iter::once(focus_var).collect();
405    let mut ordered: Vec<TripleScan> = Vec::with_capacity(scans.len());
406    let mut remaining: Vec<TripleScan> = std::mem::take(scans);
407
408    while !remaining.is_empty() {
409        let best = remaining
410            .iter()
411            .enumerate()
412            .min_by_key(|(_, s)| estimate_scan_cost(s, &bound, stats))
413            .map(|(i, _)| i)
414            .unwrap();
415        let scan = remaining.remove(best);
416        for v in scan_free_vars(&scan, &bound) {
417            bound.insert(v);
418        }
419        ordered.push(scan);
420    }
421    *scans = ordered;
422}
423
424/// Variables in `scan` that are not yet in `bound` (they become bound after
425/// this scan executes).
426fn scan_free_vars(scan: &TripleScan, bound: &HashSet<VarId>) -> Vec<VarId> {
427    [&scan.subject, &scan.predicate, &scan.object]
428        .into_iter()
429        .filter_map(|t| {
430            if let ScanTerm::Var(v) = t {
431                (!bound.contains(v)).then_some(*v)
432            } else {
433                None
434            }
435        })
436        .collect()
437}
438
439/// Estimated output row count for `scan` given `bound`. Uses a tiered model:
440/// membership check → SP/PO range → predicate-only → subject/object range
441/// → full scan. Constant predicates are looked up in `stats.predicate_cardinality`;
442/// bound-variable predicates use the average cardinality.
443fn estimate_scan_cost(scan: &TripleScan, bound: &HashSet<VarId>, stats: &PlanStats) -> u64 {
444    let s = is_bound(&scan.subject, bound);
445    let p = is_bound(&scan.predicate, bound);
446    let o = is_bound(&scan.object, bound);
447    let total = stats.total_triples.max(1);
448    let ds = stats.distinct_subjects.max(1);
449    let dp = stats.distinct_predicates.max(1);
450    let dobj = stats.distinct_objects.max(1);
451
452    match (s, p, o) {
453        (true, true, true) => 1,
454        (true, true, false) => {
455            // SP range: predicate_card / distinct_subjects per predicate group
456            pred_card(&scan.predicate, stats).unwrap_or(total / dp) / ds + 1
457        }
458        (false, true, true) => pred_card(&scan.predicate, stats).unwrap_or(total / dp) / dobj + 1,
459        (false, true, false) => pred_card(&scan.predicate, stats).unwrap_or(total / dp),
460        (true, false, false) => total / ds,
461        (false, false, true) => total / dobj,
462        // S+O with free P, or fully free: treat as full scan
463        (true, false, true) | (false, false, false) => total,
464    }
465}
466
467fn is_bound(term: &ScanTerm, bound: &HashSet<VarId>) -> bool {
468    match term {
469        ScanTerm::Const(_) => true,
470        ScanTerm::Var(v) => bound.contains(v),
471    }
472}
473
474/// Predicate cardinality for a constant-predicate scan term, `None` for
475/// variable predicates (caller uses average) or unknown predicates (cost = 0,
476/// the scan will immediately produce no rows — pick it first).
477fn pred_card(term: &ScanTerm, stats: &PlanStats) -> Option<u64> {
478    match term {
479        ScanTerm::Const(t) => Some(*stats.predicate_cardinality.get(t).unwrap_or(&0)),
480        ScanTerm::Var(_) => None,
481    }
482}
483
484// ── Join tree flattening and reordering ──────────────────────────────────────
485
486/// Collect all BGP/Path leaves from a tree of Join nodes into `out`.
487/// Returns `false` if any leaf is not a BGP or Path — non-monotone nodes
488/// (Filter, Union, …) break commutativity so we can't freely reorder them.
489fn collect_join_leaves<'a>(pattern: &'a GraphPattern, out: &mut Vec<&'a GraphPattern>) -> bool {
490    match pattern {
491        GraphPattern::Join { left, right } => {
492            collect_join_leaves(left, out) && collect_join_leaves(right, out)
493        }
494        GraphPattern::Bgp { .. } | GraphPattern::Path { .. } => {
495            out.push(pattern);
496            true
497        }
498        _ => false,
499    }
500}
501
502/// Lower a flat list of BGP/Path leaves in greedy least-cost order.
503///
504/// Starting from `{?this}` as the initially bound set, at each step the
505/// cheapest remaining leaf is lowered first and its output variables added to
506/// the bound set before costing the next leaf.  This is the join-level
507/// generalisation of `reorder_bgp` and subsumes the earlier two-arm case.
508fn lower_join_leaves(
509    b: &mut Builder,
510    mut leaves: Vec<&GraphPattern>,
511    input: OpId,
512    graph: &GraphScan,
513    stats: &PlanStats,
514) -> Result<OpId, String> {
515    let mut bound: HashSet<String> = std::iter::once(FOCUS_VAR.to_string()).collect();
516    let mut current = input;
517    while !leaves.is_empty() {
518        let costs: Vec<u64> = leaves
519            .iter()
520            .map(|p| estimate_pattern_cost(p, &bound, stats))
521            .collect();
522        let best = costs
523            .iter()
524            .enumerate()
525            .min_by_key(|(_, c)| *c)
526            .map(|(i, _)| i)
527            .unwrap();
528        let pat = leaves.remove(best);
529        bound.extend(pattern_new_vars(pat, &bound));
530        current = lower_pattern(b, pat, current, graph, Some(stats))?;
531    }
532    Ok(current)
533}
534
535/// Variables that `pattern` will newly bind (not already present in `bound`).
536fn pattern_new_vars(pattern: &GraphPattern, bound: &HashSet<String>) -> Vec<String> {
537    let mut vars: HashSet<String> = HashSet::new();
538    match pattern {
539        GraphPattern::Bgp { patterns } => {
540            for tp in patterns {
541                match &tp.subject {
542                    TermPattern::Variable(v) if !bound.contains(v.as_str()) => {
543                        vars.insert(v.as_str().to_string());
544                    }
545                    // Blank nodes act as join variables; track them with "_:<id>"
546                    // so downstream cost estimates see them as bound after this BGP.
547                    TermPattern::BlankNode(bn) => {
548                        let name = format!("_:{}", bn.as_str());
549                        if !bound.contains(&name) {
550                            vars.insert(name);
551                        }
552                    }
553                    _ => {}
554                }
555                if let NamedNodePattern::Variable(v) = &tp.predicate
556                    && !bound.contains(v.as_str())
557                {
558                    vars.insert(v.as_str().to_string());
559                }
560                match &tp.object {
561                    TermPattern::Variable(v) if !bound.contains(v.as_str()) => {
562                        vars.insert(v.as_str().to_string());
563                    }
564                    TermPattern::BlankNode(bn) => {
565                        let name = format!("_:{}", bn.as_str());
566                        if !bound.contains(&name) {
567                            vars.insert(name);
568                        }
569                    }
570                    _ => {}
571                }
572            }
573        }
574        GraphPattern::Path {
575            subject, object, ..
576        } => {
577            match subject {
578                TermPattern::Variable(v) if !bound.contains(v.as_str()) => {
579                    vars.insert(v.as_str().to_string());
580                }
581                TermPattern::BlankNode(bn) => {
582                    let name = format!("_:{}", bn.as_str());
583                    if !bound.contains(&name) {
584                        vars.insert(name);
585                    }
586                }
587                _ => {}
588            }
589            match object {
590                TermPattern::Variable(v) if !bound.contains(v.as_str()) => {
591                    vars.insert(v.as_str().to_string());
592                }
593                TermPattern::BlankNode(bn) => {
594                    let name = format!("_:{}", bn.as_str());
595                    if !bound.contains(&name) {
596                        vars.insert(name);
597                    }
598                }
599                _ => {}
600            }
601        }
602        _ => {}
603    }
604    vars.into_iter().collect()
605}
606
607/// Estimated cost of evaluating `pattern` given `bound` variable names.
608/// For BGPs this is the minimum single-triple cost across all patterns — the
609/// bound set propagates so the cheapest entry point drives the estimate.
610fn estimate_pattern_cost(
611    pattern: &GraphPattern,
612    bound: &HashSet<String>,
613    stats: &PlanStats,
614) -> u64 {
615    let total = stats.total_triples.max(1);
616    let ds = stats.distinct_subjects.max(1);
617    let dp = stats.distinct_predicates.max(1);
618    let dobj = stats.distinct_objects.max(1);
619
620    match pattern {
621        GraphPattern::Bgp { patterns } => {
622            // Simulate greedy BGP evaluation: propagate bound variables and
623            // accumulate output cardinality across steps. Taking only the minimum
624            // single-triple cost underestimates the true fan-out when some triples
625            // have unbound variables that no other triple in the BGP will bind.
626            let tp_cost = |tp: &TriplePattern, sim_bound: &HashSet<String>| -> u64 {
627                let s = tp_name_is_bound(&tp.subject, sim_bound);
628                let p = nnp_name_is_bound(&tp.predicate, sim_bound);
629                let o = tp_name_is_bound(&tp.object, sim_bound);
630                let pred_est = match &tp.predicate {
631                    NamedNodePattern::NamedNode(nn) => stats
632                        .predicate_cardinality
633                        .get(&Term::NamedNode(nn.clone()))
634                        .copied()
635                        .unwrap_or(total / dp),
636                    NamedNodePattern::Variable(_) => total / dp,
637                };
638                match (s, p, o) {
639                    (true, true, true) => 1,
640                    (true, true, false) => pred_est / ds + 1,
641                    (false, true, true) => pred_est / dobj + 1,
642                    (false, true, false) => pred_est,
643                    (true, false, false) => total / ds,
644                    (false, false, true) => total / dobj,
645                    _ => total,
646                }
647            };
648            let mut sim_bound = bound.clone();
649            let mut remaining: Vec<&TriplePattern> = patterns.iter().collect();
650            let mut output: u64 = 1;
651            let mut first = true;
652            while !remaining.is_empty() {
653                let best = remaining
654                    .iter()
655                    .enumerate()
656                    .min_by_key(|(_, tp)| tp_cost(tp, &sim_bound))
657                    .map(|(i, _)| i)
658                    .unwrap();
659                let tp = remaining.remove(best);
660                let step_cost = tp_cost(tp, &sim_bound);
661                // Always multiply for the first (access) triple. For subsequent
662                // triples, only multiply when the subject is unbound — that is the
663                // true cross-product case. Anchored triples (bound subject) don't
664                // fan out; multiplying their total/ds estimate would overestimate
665                // and cause the planner to incorrectly prefer an unbound path scan.
666                let s_bound = tp_name_is_bound(&tp.subject, &sim_bound);
667                if first || !s_bound {
668                    output = output.saturating_mul(step_cost);
669                }
670                first = false;
671                match &tp.subject {
672                    TermPattern::Variable(v) => {
673                        sim_bound.insert(v.as_str().to_string());
674                    }
675                    TermPattern::BlankNode(bn) => {
676                        sim_bound.insert(format!("_:{}", bn.as_str()));
677                    }
678                    _ => {}
679                }
680                if let NamedNodePattern::Variable(v) = &tp.predicate {
681                    sim_bound.insert(v.as_str().to_string());
682                }
683                match &tp.object {
684                    TermPattern::Variable(v) => {
685                        sim_bound.insert(v.as_str().to_string());
686                    }
687                    TermPattern::BlankNode(bn) => {
688                        sim_bound.insert(format!("_:{}", bn.as_str()));
689                    }
690                    _ => {}
691                }
692            }
693            output
694        }
695
696        GraphPattern::Path {
697            subject, object, ..
698        } => {
699            let s = tp_name_is_bound(subject, bound);
700            let o = tp_name_is_bound(object, bound);
701            match (s, o) {
702                (true, true) => 1,
703                (true, false) | (false, true) => total / ds / 2 + 1,
704                (false, false) => total,
705            }
706        }
707
708        _ => total,
709    }
710}
711
712fn tp_name_is_bound(tp: &TermPattern, bound: &HashSet<String>) -> bool {
713    match tp {
714        TermPattern::Variable(v) => bound.contains(v.as_str()),
715        // Blank nodes act as non-distinguished join variables in the executor
716        // (lower_term_pattern converts them to ScanTerm::Var). Track them
717        // using the same "_:<id>" naming convention so cost estimates correctly
718        // treat them as unbound until the corresponding BGP triple has run.
719        TermPattern::BlankNode(bn) => bound.contains(&format!("_:{}", bn.as_str())),
720        _ => true, // NamedNode, Literal are always-bound constants
721    }
722}
723
724fn nnp_name_is_bound(nnp: &NamedNodePattern, bound: &HashSet<String>) -> bool {
725    match nnp {
726        NamedNodePattern::NamedNode(_) => true,
727        NamedNodePattern::Variable(v) => bound.contains(v.as_str()),
728    }
729}
730
731fn lower_triple(
732    b: &mut Builder,
733    tp: &TriplePattern,
734    graph: &GraphScan,
735) -> Result<TripleScan, String> {
736    Ok(TripleScan {
737        subject: lower_term_pattern(b, &tp.subject)?,
738        predicate: lower_named_node_pattern(b, &tp.predicate)?,
739        object: lower_term_pattern(b, &tp.object)?,
740        graph: graph.clone(),
741    })
742}
743
744fn lower_term_pattern(b: &mut Builder, tp: &TermPattern) -> Result<ScanTerm, String> {
745    match tp {
746        TermPattern::Variable(v) => Ok(ScanTerm::Var(b.var(v.as_str()))),
747        // A query blank node behaves as a non-distinguished join variable.
748        TermPattern::BlankNode(bn) => Ok(ScanTerm::Var(b.var(&format!("_:{}", bn.as_str())))),
749        TermPattern::NamedNode(n) => Ok(ScanTerm::Const(Term::NamedNode(n.clone()))),
750        TermPattern::Literal(l) => Ok(ScanTerm::Const(Term::Literal(l.clone()))),
751        #[allow(unreachable_patterns)]
752        _ => Err("rdf-star triple term".into()),
753    }
754}
755
756fn lower_named_node_pattern(b: &mut Builder, np: &NamedNodePattern) -> Result<ScanTerm, String> {
757    match np {
758        NamedNodePattern::NamedNode(n) => Ok(ScanTerm::Const(Term::NamedNode(n.clone()))),
759        NamedNodePattern::Variable(v) => Ok(ScanTerm::Var(b.var(v.as_str()))),
760    }
761}
762
763/// Lower a top-level property-path pattern between two endpoints, threading
764/// `input`. The translatable connectives (predicate, inverse, sequence,
765/// alternative) decompose into `Scan`/`Join`/`Union` — matching SPARQL's own
766/// translation, which keeps *multiset* semantics. Only the arbitrary-length
767/// operators (`*`/`+`/`?`) become a `PathScan` with distinct semantics.
768fn lower_path(
769    b: &mut Builder,
770    subject: ScanTerm,
771    path: &PropertyPathExpression,
772    object: ScanTerm,
773    input: OpId,
774    graph: &GraphScan,
775) -> Result<OpId, String> {
776    match path {
777        PropertyPathExpression::NamedNode(p) => {
778            let pattern = TripleScan {
779                subject,
780                predicate: ScanTerm::Const(Term::NamedNode(p.clone())),
781                object,
782                graph: graph.clone(),
783            };
784            Ok(b.push(NativeOp::Scan { input, pattern }))
785        }
786        // ^p : evaluate p with the endpoints swapped.
787        PropertyPathExpression::Reverse(p) => lower_path(b, object, p, subject, input, graph),
788        // p1/p2 : join through a fresh midpoint (preserves duplicates).
789        PropertyPathExpression::Sequence(p1, p2) => {
790            let mid = ScanTerm::Var(b.fresh_var());
791            let first = lower_path(b, subject, p1, mid.clone(), input, graph)?;
792            lower_path(b, mid, p2, object, first, graph)
793        }
794        // p1|p2 : union of both translations over the same input.
795        PropertyPathExpression::Alternative(p1, p2) => {
796            let left = lower_path(b, subject.clone(), p1, object.clone(), input, graph)?;
797            let right = lower_path(b, subject, p2, object, input, graph)?;
798            Ok(b.push(NativeOp::Union { left, right }))
799        }
800        PropertyPathExpression::ZeroOrMore(p) => {
801            lower_closure(b, subject, p, object, ClosureKind::Star, input, graph)
802        }
803        PropertyPathExpression::OneOrMore(p) => {
804            lower_closure(b, subject, p, object, ClosureKind::Plus, input, graph)
805        }
806        PropertyPathExpression::ZeroOrOne(p) => {
807            lower_closure(b, subject, p, object, ClosureKind::Opt, input, graph)
808        }
809        PropertyPathExpression::NegatedPropertySet(_) => Err("negated property set".into()),
810    }
811}
812
813/// Convert a spargebra `PropertyPathExpression` to the `shifty_algebra::Path`
814/// algebra. Returns `None` for `NegatedPropertySet`, which has no equivalent.
815fn property_path_to_algebra(path: &PropertyPathExpression) -> Option<Path> {
816    match path {
817        PropertyPathExpression::NamedNode(n) => Some(Path::Pred(n.clone())),
818        PropertyPathExpression::Reverse(p) => {
819            property_path_to_algebra(p).map(|inner| inner.inverse())
820        }
821        PropertyPathExpression::Sequence(a, b) => {
822            let la = property_path_to_algebra(a)?;
823            let lb = property_path_to_algebra(b)?;
824            Some(Path::seq(vec![la, lb]))
825        }
826        PropertyPathExpression::Alternative(a, b) => {
827            let la = property_path_to_algebra(a)?;
828            let lb = property_path_to_algebra(b)?;
829            Some(Path::alt(vec![la, lb]))
830        }
831        PropertyPathExpression::ZeroOrMore(p) => {
832            property_path_to_algebra(p).map(|inner| inner.star())
833        }
834        PropertyPathExpression::OneOrMore(p) => {
835            property_path_to_algebra(p).map(|inner| inner.one_or_more())
836        }
837        PropertyPathExpression::ZeroOrOne(p) => {
838            property_path_to_algebra(p).map(|inner| inner.zero_or_one())
839        }
840        PropertyPathExpression::NegatedPropertySet(_) => None,
841    }
842}
843
844/// Lower an arbitrary-length path into a `PathScan`. The repeated step `p` is
845/// converted to the `shifty_algebra::Path` algebra (evaluated set-at-a-time by
846/// the executor); a step with no algebra equivalent forces fallback.
847fn lower_closure(
848    b: &mut Builder,
849    subject: ScanTerm,
850    p: &PropertyPathExpression,
851    object: ScanTerm,
852    kind: ClosureKind,
853    input: OpId,
854    graph: &GraphScan,
855) -> Result<OpId, String> {
856    let step = property_path_to_algebra(p).ok_or("negated property set in closure")?;
857    let scan = PathScan {
858        subject,
859        object,
860        step,
861        kind,
862        graph: graph.clone(),
863    };
864    Ok(b.push(NativeOp::PathScan { input, scan }))
865}
866
867/// Lower an expression. The safe boolean subset (`BOUND`, `&&`, `||`, `!`,
868/// `sameTerm`) and correlated `EXISTS` are evaluated natively; everything whose
869/// Spareval semantics we don't yet reproduce exactly (value equality, ordered
870/// comparison, arithmetic, `IN`, functions) forces whole-query fallback rather
871/// than risk a silent disagreement. `graph` is the enclosing graph scope, used
872/// for the `EXISTS` sub-pattern.
873fn lower_expr(
874    b: &mut Builder,
875    expr: &Expression,
876    graph: &GraphScan,
877    stats: Option<&PlanStats>,
878) -> Result<ExprPlan, String> {
879    match expr {
880        Expression::Variable(v) => Ok(ExprPlan::Var(b.var(v.as_str()))),
881        Expression::NamedNode(n) => Ok(ExprPlan::Const(Term::NamedNode(n.clone()))),
882        Expression::Literal(l) => Ok(ExprPlan::Const(Term::Literal(l.clone()))),
883        Expression::Bound(v) => Ok(ExprPlan::Bound(b.var(v.as_str()))),
884        Expression::Not(a) => Ok(ExprPlan::Not(Box::new(lower_expr(b, a, graph, stats)?))),
885        Expression::And(a, c) => Ok(ExprPlan::And(
886            Box::new(lower_expr(b, a, graph, stats)?),
887            Box::new(lower_expr(b, c, graph, stats)?),
888        )),
889        Expression::Or(a, c) => Ok(ExprPlan::Or(
890            Box::new(lower_expr(b, a, graph, stats)?),
891            Box::new(lower_expr(b, c, graph, stats)?),
892        )),
893        Expression::SameTerm(a, c) => Ok(ExprPlan::SameTerm(
894            Box::new(lower_expr(b, a, graph, stats)?),
895            Box::new(lower_expr(b, c, graph, stats)?),
896        )),
897        Expression::Equal(a, c) => Ok(ExprPlan::Equal(
898            Box::new(lower_expr(b, a, graph, stats)?),
899            Box::new(lower_expr(b, c, graph, stats)?),
900        )),
901        // Correlated EXISTS / NOT EXISTS: lower the sub-pattern into the same
902        // arena, rooted at its own InputFocus (seeded with the current solution
903        // at evaluation time). NOT EXISTS arrives as Not(Exists(..)).
904        Expression::Exists(pattern) => {
905            let leaf = b.push(NativeOp::InputFocus);
906            let root = lower_pattern(b, pattern, leaf, graph, stats)?;
907            Ok(ExprPlan::Exists(root))
908        }
909        Expression::Greater(..)
910        | Expression::GreaterOrEqual(..)
911        | Expression::Less(..)
912        | Expression::LessOrEqual(..) => Err("ordered comparison".into()),
913        Expression::In(..) => Err("IN".into()),
914        Expression::Add(..)
915        | Expression::Subtract(..)
916        | Expression::Multiply(..)
917        | Expression::Divide(..)
918        | Expression::UnaryPlus(_)
919        | Expression::UnaryMinus(_) => Err("arithmetic".into()),
920        Expression::If(..) => Err("IF".into()),
921        Expression::Coalesce(_) => Err("COALESCE".into()),
922        Expression::FunctionCall(function, args) => match (function, args.as_slice()) {
923            (Function::Str, [arg]) => {
924                Ok(ExprPlan::Str(Box::new(lower_expr(b, arg, graph, stats)?)))
925            }
926            (Function::StrStarts, [text, prefix]) => Ok(ExprPlan::StrStarts(
927                Box::new(lower_expr(b, text, graph, stats)?),
928                Box::new(lower_expr(b, prefix, graph, stats)?),
929            )),
930            _ => Err("function call".into()),
931        },
932    }
933}
934
935#[cfg(test)]
936mod tests {
937    use super::*;
938    use spargebra::SparqlParser;
939
940    fn parse(q: &str) -> Query {
941        SparqlParser::new().parse_query(q).unwrap()
942    }
943
944    #[test]
945    fn lowers_simple_bgp_select() {
946        let q = parse("SELECT ?value WHERE { ?this <http://ex/p> ?value }");
947        let plan = lower_query(&q).expect("should lower");
948        assert_eq!(plan.form, QueryForm::Select);
949        // InputFocus + one Scan + Project
950        assert!(matches!(plan.nodes[0], NativeOp::InputFocus));
951        assert!(
952            plan.nodes
953                .iter()
954                .any(|op| matches!(op, NativeOp::Scan { .. }))
955        );
956        assert!(plan.var_names.iter().any(|n| n == "value"));
957    }
958
959    #[test]
960    fn lowers_ask() {
961        let q = parse("ASK { ?this <http://ex/p> ?o }");
962        let plan = lower_query(&q).expect("should lower");
963        assert_eq!(plan.form, QueryForm::Ask);
964    }
965
966    #[test]
967    fn lowers_union() {
968        let q = parse(
969            "SELECT ?o WHERE { { ?this <http://ex/p> ?o } UNION { ?this <http://ex/q> ?o } }",
970        );
971        let plan = lower_query(&q).expect("should lower");
972        assert!(
973            plan.nodes
974                .iter()
975                .any(|op| matches!(op, NativeOp::Union { .. }))
976        );
977    }
978
979    #[test]
980    fn lowers_safe_filter() {
981        let q = parse("ASK { ?this <http://ex/p> ?o FILTER (bound(?o) && !sameTerm(?o, ?this)) }");
982        let plan = lower_query(&q).expect("should lower");
983        assert!(
984            plan.nodes
985                .iter()
986                .any(|op| matches!(op, NativeOp::Filter { .. }))
987        );
988    }
989
990    #[test]
991    fn lowers_strstarts_over_str() {
992        let q = parse("ASK { ?this ?p ?o FILTER (STRSTARTS(STR(?p), \"http://ex/\")) }");
993        lower_query(&q).expect("STRSTARTS(STR(...), literal) should lower");
994    }
995
996    #[test]
997    fn arbitrary_length_path_lowers_to_pathscan() {
998        let q = parse("ASK { ?this <http://ex/p>* ?o }");
999        let plan = lower_query(&q).expect("should lower");
1000        assert!(plan.nodes.iter().any(|op| matches!(
1001            op,
1002            NativeOp::PathScan { scan, .. } if scan.kind == ClosureKind::Star
1003        )));
1004    }
1005
1006    #[test]
1007    fn sequence_path_decomposes_to_scans() {
1008        // p/q is multiset-translated to two joined scans, not a PathScan.
1009        let q = parse("SELECT ?o WHERE { ?this <http://ex/p>/<http://ex/q> ?o }");
1010        let plan = lower_query(&q).expect("should lower");
1011        assert_eq!(
1012            plan.nodes
1013                .iter()
1014                .filter(|op| matches!(op, NativeOp::Scan { .. }))
1015                .count(),
1016            2
1017        );
1018        assert!(
1019            !plan
1020                .nodes
1021                .iter()
1022                .any(|op| matches!(op, NativeOp::PathScan { .. }))
1023        );
1024    }
1025
1026    #[test]
1027    fn not_exists_filter_lowers() {
1028        let q = parse("ASK { ?this <http://ex/p> ?o FILTER NOT EXISTS { ?o <http://ex/q> ?w } }");
1029        let plan = lower_query(&q).expect("should lower");
1030        assert!(
1031            plan.nodes
1032                .iter()
1033                .any(|op| matches!(op, NativeOp::Filter { .. }))
1034        );
1035    }
1036
1037    #[test]
1038    fn negated_property_set_falls_back() {
1039        let q = parse("ASK { ?this !<http://ex/p> ?o }");
1040        assert!(lower_query(&q).is_err());
1041    }
1042
1043    #[test]
1044    fn value_equality_lowers() {
1045        let q = parse("ASK { ?this <http://ex/p> ?o FILTER (?o = <http://ex/target>) }");
1046        lower_query(&q).expect("= over IRIs should lower");
1047    }
1048
1049    #[test]
1050    fn optional_falls_back() {
1051        let q =
1052            parse("SELECT ?o WHERE { ?this <http://ex/p> ?o OPTIONAL { ?o <http://ex/q> ?w } }");
1053        assert!(lower_query(&q).is_err());
1054    }
1055
1056    #[test]
1057    fn fixed_graph_block_lowers() {
1058        let q = parse("ASK { GRAPH <urn:g> { ?this <http://ex/p> ?o } }");
1059        let plan = lower_query(&q).expect("should lower");
1060        assert!(plan.nodes.iter().any(
1061            |op| matches!(op, NativeOp::Scan { pattern, .. } if matches!(pattern.graph, GraphScan::Named(_)))
1062        ));
1063    }
1064
1065    /// A two-triple BGP where the second pattern has a rare predicate should be
1066    /// reordered first when statistics say so.
1067    #[test]
1068    fn bgp_reorder_moves_selective_scan_first() {
1069        // ?x ?y ?z . ?this <rare> ?x — without stats: $this-scan is first,
1070        // rare-predicate-scan is second. With stats that give <rare> cardinality
1071        // 1 (vs. the first scan which has an unbound predicate, cost = total),
1072        // the planner should move the rare scan first.
1073        let q = parse("SELECT ?x WHERE { ?x ?y ?z . ?this <http://ex/rare> ?x }");
1074        let rare = Term::NamedNode(NamedNode::new_unchecked("http://ex/rare"));
1075        let mut pcard = HashMap::new();
1076        pcard.insert(rare.clone(), 1u64);
1077        let stats = PlanStats {
1078            total_triples: 100_000,
1079            distinct_subjects: 10_000,
1080            distinct_objects: 10_000,
1081            distinct_predicates: 50,
1082            predicate_cardinality: pcard,
1083        };
1084        let plan = lower_query_with_stats(&q, Some(&stats)).expect("should lower");
1085        // Collect scans in pipeline order (InputFocus → first Scan → second Scan …)
1086        let scans: Vec<&TripleScan> = plan
1087            .nodes
1088            .iter()
1089            .filter_map(|op| {
1090                if let NativeOp::Scan { pattern, .. } = op {
1091                    Some(pattern)
1092                } else {
1093                    None
1094                }
1095            })
1096            .collect();
1097        // The rare predicate scan must come before the free-predicate scan.
1098        let rare_pos = scans
1099            .iter()
1100            .position(|s| matches!(&s.predicate, ScanTerm::Const(t) if t == &rare));
1101        let free_pos = scans
1102            .iter()
1103            .position(|s| matches!(&s.predicate, ScanTerm::Var(_)));
1104        assert!(
1105            rare_pos < free_pos,
1106            "expected rare-predicate scan first; got rare={rare_pos:?} free={free_pos:?}",
1107        );
1108    }
1109
1110    /// `Join(Join(Bgp1, Bgp2), Bgp3)` — a nested join where the outer left arm
1111    /// is itself a Join — should be flattened into three leaves and the rare-
1112    /// predicate leaf placed first, even though two-arm reordering of the outer
1113    /// join would leave Bgp3 stranded at the end.
1114    #[test]
1115    fn join_flattens_three_way_nested() {
1116        use spargebra::term::{TriplePattern, Variable};
1117
1118        let nn = |s: &str| NamedNode::new_unchecked(s);
1119        let var = |s: &str| Variable::new_unchecked(s);
1120        let bgp = |s: &str, p: &str, o: &str| GraphPattern::Bgp {
1121            patterns: vec![TriplePattern {
1122                subject: TermPattern::Variable(var(s)),
1123                predicate: NamedNodePattern::NamedNode(nn(p)),
1124                object: TermPattern::Variable(var(o)),
1125            }],
1126        };
1127
1128        // Bgp1: ?this :common ?x  — ?this bound → SP range, cost ≈ 2
1129        // Bgp2: ?x :medium ?y    — nothing bound, cost = medium_card = 10_000
1130        // Bgp3: ?x :rare ?y      — nothing bound, cost = rare_card = 1
1131        // Naive order: Bgp1, Bgp2, Bgp3 (outer Join sees a Join on the left,
1132        //   not a Bgp/Path, so two-arm reordering doesn't fire for the outer).
1133        // Flat greedy order: Bgp3 (cost 1), then Bgp1 and Bgp2 (both cost 1
1134        //   once ?x and ?y are bound).
1135        let bgp1 = bgp("this", "http://ex/common", "x");
1136        let bgp2 = bgp("x", "http://ex/medium", "y");
1137        let bgp3 = bgp("x", "http://ex/rare", "y");
1138
1139        let query = Query::Ask {
1140            pattern: GraphPattern::Join {
1141                left: Box::new(GraphPattern::Join {
1142                    left: Box::new(bgp1),
1143                    right: Box::new(bgp2),
1144                }),
1145                right: Box::new(bgp3),
1146            },
1147            dataset: None,
1148            base_iri: None,
1149        };
1150
1151        let mut pcard = HashMap::new();
1152        pcard.insert(Term::NamedNode(nn("http://ex/common")), 10_000u64);
1153        pcard.insert(Term::NamedNode(nn("http://ex/medium")), 10_000u64);
1154        pcard.insert(Term::NamedNode(nn("http://ex/rare")), 1u64);
1155        let stats = PlanStats {
1156            total_triples: 100_000,
1157            distinct_subjects: 10_000,
1158            distinct_objects: 10_000,
1159            distinct_predicates: 50,
1160            predicate_cardinality: pcard,
1161        };
1162
1163        let plan = lower_query_with_stats(&query, Some(&stats)).expect("should lower");
1164
1165        let rare = Term::NamedNode(nn("http://ex/rare"));
1166        let scans: Vec<&TripleScan> = plan
1167            .nodes
1168            .iter()
1169            .filter_map(|op| {
1170                if let NativeOp::Scan { pattern, .. } = op {
1171                    Some(pattern)
1172                } else {
1173                    None
1174                }
1175            })
1176            .collect();
1177
1178        assert_eq!(scans.len(), 3, "expected 3 scans; got {}", scans.len());
1179        assert!(
1180            matches!(&scans[0].predicate, ScanTerm::Const(t) if t == &rare),
1181            "rare-predicate scan should be first; got predicates: {:?}",
1182            scans.iter().map(|s| &s.predicate).collect::<Vec<_>>()
1183        );
1184    }
1185
1186    /// A `Join(Bgp, Path)` where the path arm has a constant endpoint should be
1187    /// reordered to `Join(Path, Bgp)` when stats are present, because the path
1188    /// closure is cheaper than the SP range scan for the BGP arm.
1189    #[test]
1190    fn join_reorders_path_before_bgp_when_cheaper() {
1191        // "?this ?p ?v . ?p <rdf:type>* <ex:X>" — BGP has free predicate (cost
1192        // ≈ total/subjects) while Path has a constant object (cost ≈ total/subjects/2).
1193        let q = parse(
1194            "ASK { ?this ?p ?v \
1195             . ?p <http://www.w3.org/1999/02/22-rdf-syntax-ns#type>* <http://ex/X> }",
1196        );
1197        let stats = PlanStats {
1198            total_triples: 100_000,
1199            distinct_subjects: 10_000,
1200            distinct_objects: 10_000,
1201            distinct_predicates: 50,
1202            predicate_cardinality: HashMap::new(),
1203        };
1204        let plan = lower_query_with_stats(&q, Some(&stats)).expect("should lower");
1205        // After swap the PathScan should feed directly from InputFocus (node 0).
1206        let path_inputs_focus = plan
1207            .nodes
1208            .iter()
1209            .any(|op| matches!(op, NativeOp::PathScan { input, .. } if *input == 0));
1210        assert!(
1211            path_inputs_focus,
1212            "PathScan should be evaluated first (input = InputFocus)"
1213        );
1214    }
1215}