Skip to main content

lora_compiler/
optimizer.rs

1use crate::logical::*;
2use crate::physical::*;
3use lora_analyzer::{symbols::VarId, LiteralValue, ResolvedExpr};
4use lora_ast::BinaryOp;
5use lora_store::GraphStats;
6use std::collections::BTreeSet;
7
8pub struct Optimizer;
9
10impl Default for Optimizer {
11    fn default() -> Self {
12        Self::new()
13    }
14}
15
16impl Optimizer {
17    pub fn new() -> Self {
18        Self
19    }
20
21    /// Run the rewrite pipeline. `stats` drives cost-based selection
22    /// when multiple index rewrites match the same `Filter(NodeScan)`;
23    /// pass [`GraphStats::default()`] to disable scoring (every rewrite
24    /// commits unconditionally, the pre-cost-model behaviour).
25    pub fn optimize(&mut self, mut plan: LogicalPlan, stats: &GraphStats) -> LogicalPlan {
26        self.push_filter_below_projection(&mut plan);
27        self.use_indexed_node_scans(&mut plan, stats);
28        self.use_indexed_rel_scans(&mut plan, stats);
29        self.annotate_top_k_sorts(&mut plan);
30        self.remove_redundant_limit(&mut plan);
31        plan
32    }
33
34    fn push_filter_below_projection(&self, plan: &mut LogicalPlan) {
35        let len = plan.nodes.len();
36
37        for i in 0..len {
38            let input_id = match &plan.nodes[i] {
39                LogicalOp::Filter(f) => f.input,
40                _ => continue,
41            };
42
43            let Some(input) = plan.nodes.get(input_id) else {
44                continue;
45            };
46
47            if !can_push_filter_below_projection(&plan.nodes[i], input) {
48                continue;
49            }
50
51            push_filter_below_projection_at(plan, i, input_id);
52        }
53    }
54
55    fn remove_redundant_limit(&self, _plan: &mut LogicalPlan) {
56        // placeholder for future rules
57    }
58
59    fn annotate_top_k_sorts(&self, plan: &mut LogicalPlan) {
60        let len = plan.nodes.len();
61        for i in 0..len {
62            let Some((input, bound)) = limit_sort_bound(&plan.nodes[i]) else {
63                continue;
64            };
65
66            if let Some(sort) = sort_op_mut(&mut plan.nodes[input]) {
67                sort.top_k = merge_top_k_bound(sort.top_k, bound);
68            }
69        }
70    }
71
72    /// Cost-driven rewrite pass. For each `Filter(NodeScan)` pattern,
73    /// collect every catalog-backed index rewrite (equality, range,
74    /// text, spatial), drop tautological candidates that select every
75    /// row, and commit the cheapest candidate when it strictly beats
76    /// the un-rewritten scan according to [`score_logical_op`].
77    fn use_indexed_node_scans(&self, plan: &mut LogicalPlan, stats: &GraphStats) {
78        let len = plan.nodes.len();
79        for i in 0..len {
80            let (input_id, predicate) = match &plan.nodes[i] {
81                LogicalOp::Filter(f) => (f.input, f.predicate.clone()),
82                _ => continue,
83            };
84            let LogicalOp::NodeScan(scan) = &plan.nodes[input_id] else {
85                continue;
86            };
87
88            let candidates = collect_index_candidates(scan, &predicate, stats);
89            let Some(best) = pick_best_candidate(&plan.nodes[input_id], candidates, stats) else {
90                continue;
91            };
92            plan.nodes[input_id] = best;
93        }
94    }
95
96    /// Rewrite `Filter(Expand(NodeScan(empty_labels), ...), pred-on-rel)`
97    /// into a relationship-targeted index scan when the predicate
98    /// matches a known shape (`r.prop CMP value`, `r.prop STARTS WITH …`,
99    /// `point.withinBBox(r.prop, …)`). The rewrite is gated on the
100    /// source NodeScan being unconstrained. The original `Filter` stays
101    /// in place so any residual predicates outside the extracted index
102    /// condition still run with normal expression semantics.
103    fn use_indexed_rel_scans(&self, plan: &mut LogicalPlan, stats: &GraphStats) {
104        let len = plan.nodes.len();
105        for i in 0..len {
106            let (filter_input, predicate) = match &plan.nodes[i] {
107                LogicalOp::Filter(f) => (f.input, f.predicate.clone()),
108                _ => continue,
109            };
110            let LogicalOp::Expand(expand) = &plan.nodes[filter_input] else {
111                continue;
112            };
113            // Variable-length expansions and inline rel-property
114            // patterns aren't handled by the index-targeted scan op.
115            if expand.range.is_some() || expand.rel_properties.is_some() {
116                continue;
117            }
118            let Some(rel_var) = expand.rel else {
119                continue;
120            };
121            // Source must be a root, unconstrained NodeScan; otherwise
122            // replacing it can bypass upstream rows or an already-bound
123            // source variable.
124            let LogicalOp::NodeScan(src_scan) = &plan.nodes[expand.input] else {
125                continue;
126            };
127            if src_scan.input.is_some() || !src_scan.labels.is_empty() {
128                continue;
129            }
130            let nodescan_input = src_scan.input;
131            let expand = expand.clone();
132
133            let candidates =
134                collect_rel_index_candidates(&expand, &predicate, rel_var, nodescan_input, stats);
135            let Some(best) = pick_best_candidate(&plan.nodes[filter_input], candidates, stats)
136            else {
137                continue;
138            };
139            // The new scan binds (src, rel, dst) and prefilters by the
140            // indexed predicate. Keep the Filter above it so unrelated
141            // conjuncts are not dropped.
142            plan.nodes[filter_input] = best;
143        }
144    }
145
146    /// Lower a logical plan by consuming it — each op's owned payload
147    /// (expressions, patterns, items) is moved into the physical op rather
148    /// than cloned. Callers should not need the logical plan after this.
149    pub fn lower_to_physical(&mut self, logical: LogicalPlan) -> PhysicalPlan {
150        let LogicalPlan { root, nodes } = logical;
151
152        let nodes = nodes.into_iter().map(lower_logical_op).collect();
153
154        PhysicalPlan { root, nodes }
155    }
156}
157
158fn can_push_filter_below_projection(filter: &LogicalOp, input: &LogicalOp) -> bool {
159    let (LogicalOp::Filter(filter), LogicalOp::Projection(proj)) = (filter, input) else {
160        return false;
161    };
162
163    if proj.distinct || proj.include_existing {
164        return false;
165    }
166
167    let output_vars: BTreeSet<VarId> = proj.items.iter().map(|item| item.output).collect();
168    let pred_vars = collect_vars(&filter.predicate);
169    !pred_vars.iter().any(|v| output_vars.contains(v))
170}
171
172fn push_filter_below_projection_at(
173    plan: &mut LogicalPlan,
174    filter_id: PlanNodeId,
175    projection_id: PlanNodeId,
176) {
177    let filter = match plan.nodes.get(filter_id).cloned() {
178        Some(LogicalOp::Filter(f)) => f,
179        _ => return,
180    };
181    let proj = match plan.nodes.get(projection_id).cloned() {
182        Some(LogicalOp::Projection(p)) => p,
183        _ => return,
184    };
185
186    plan.nodes[projection_id] = LogicalOp::Filter(Filter {
187        input: proj.input,
188        predicate: filter.predicate,
189    });
190    plan.nodes[filter_id] = LogicalOp::Projection(Projection {
191        input: projection_id,
192        distinct: proj.distinct,
193        items: proj.items,
194        include_existing: proj.include_existing,
195    });
196}
197
198fn lower_logical_op(op: LogicalOp) -> PhysicalOp {
199    match op {
200        LogicalOp::Argument(_) => PhysicalOp::Argument(ArgumentExec),
201
202        LogicalOp::NodeScan(scan) => lower_node_scan(scan),
203
204        LogicalOp::NodeByPropertyScan(scan) => {
205            PhysicalOp::NodeByPropertyScan(NodeByPropertyScanExec {
206                input: scan.input,
207                var: scan.var,
208                labels: scan.labels,
209                key: scan.key,
210                value: scan.value,
211            })
212        }
213
214        LogicalOp::NodeByPropertyRangeScan(scan) => {
215            PhysicalOp::NodeByPropertyRangeScan(NodeByPropertyRangeScanExec {
216                input: scan.input,
217                var: scan.var,
218                labels: scan.labels,
219                key: scan.key,
220                lo: scan.lo,
221                lo_inclusive: scan.lo_inclusive,
222                hi: scan.hi,
223                hi_inclusive: scan.hi_inclusive,
224            })
225        }
226
227        LogicalOp::NodeByTextScan(scan) => PhysicalOp::NodeByTextScan(NodeByTextScanExec {
228            input: scan.input,
229            var: scan.var,
230            labels: scan.labels,
231            key: scan.key,
232            predicate: scan.predicate,
233            query: scan.query,
234        }),
235
236        LogicalOp::NodeByPointScan(scan) => PhysicalOp::NodeByPointScan(NodeByPointScanExec {
237            input: scan.input,
238            var: scan.var,
239            labels: scan.labels,
240            key: scan.key,
241            predicate: scan.predicate,
242        }),
243
244        LogicalOp::RelByPropertyRangeScan(scan) => {
245            PhysicalOp::RelByPropertyRangeScan(RelByPropertyRangeScanExec {
246                input: scan.input,
247                src: scan.src,
248                rel: scan.rel,
249                dst: scan.dst,
250                types: scan.types,
251                direction: scan.direction,
252                key: scan.key,
253                lo: scan.lo,
254                lo_inclusive: scan.lo_inclusive,
255                hi: scan.hi,
256                hi_inclusive: scan.hi_inclusive,
257            })
258        }
259
260        LogicalOp::RelByTextScan(scan) => PhysicalOp::RelByTextScan(RelByTextScanExec {
261            input: scan.input,
262            src: scan.src,
263            rel: scan.rel,
264            dst: scan.dst,
265            types: scan.types,
266            direction: scan.direction,
267            key: scan.key,
268            predicate: scan.predicate,
269            query: scan.query,
270        }),
271
272        LogicalOp::RelByPointScan(scan) => PhysicalOp::RelByPointScan(RelByPointScanExec {
273            input: scan.input,
274            src: scan.src,
275            rel: scan.rel,
276            dst: scan.dst,
277            types: scan.types,
278            direction: scan.direction,
279            key: scan.key,
280            predicate: scan.predicate,
281        }),
282
283        LogicalOp::Expand(expand) => PhysicalOp::Expand(ExpandExec {
284            input: expand.input,
285            src: expand.src,
286            rel: expand.rel,
287            dst: expand.dst,
288            types: expand.types,
289            direction: expand.direction,
290            rel_properties: expand.rel_properties,
291            range: expand.range,
292        }),
293
294        LogicalOp::Filter(filter) => PhysicalOp::Filter(FilterExec {
295            input: filter.input,
296            predicate: filter.predicate,
297        }),
298
299        LogicalOp::Projection(proj) => PhysicalOp::Projection(ProjectionExec {
300            input: proj.input,
301            distinct: proj.distinct,
302            items: proj.items,
303            include_existing: proj.include_existing,
304        }),
305
306        LogicalOp::Unwind(unwind) => PhysicalOp::Unwind(UnwindExec {
307            input: unwind.input,
308            expr: unwind.expr,
309            alias: unwind.alias,
310        }),
311
312        LogicalOp::Aggregation(agg) => PhysicalOp::HashAggregation(HashAggregationExec {
313            input: agg.input,
314            group_by: agg.group_by,
315            aggregates: agg.aggregates,
316        }),
317
318        LogicalOp::Sort(sort) => PhysicalOp::Sort(SortExec {
319            input: sort.input,
320            items: sort.items,
321            top_k: sort.top_k,
322        }),
323
324        LogicalOp::Limit(limit) => PhysicalOp::Limit(LimitExec {
325            input: limit.input,
326            skip: limit.skip,
327            limit: limit.limit,
328        }),
329
330        LogicalOp::Create(create) => PhysicalOp::Create(CreateExec {
331            input: create.input,
332            pattern: create.pattern,
333        }),
334
335        LogicalOp::Merge(merge) => PhysicalOp::Merge(MergeExec {
336            input: merge.input,
337            pattern_part: merge.pattern_part,
338            actions: merge.actions,
339        }),
340
341        LogicalOp::Delete(delete) => PhysicalOp::Delete(DeleteExec {
342            input: delete.input,
343            detach: delete.detach,
344            expressions: delete.expressions,
345        }),
346
347        LogicalOp::Set(set) => PhysicalOp::Set(SetExec {
348            input: set.input,
349            items: set.items,
350        }),
351
352        LogicalOp::Remove(remove) => PhysicalOp::Remove(RemoveExec {
353            input: remove.input,
354            items: remove.items,
355        }),
356
357        LogicalOp::Foreach(foreach) => PhysicalOp::Foreach(crate::ForeachExec {
358            input: foreach.input,
359            variable: foreach.variable,
360            list: foreach.list,
361            body: foreach.body,
362        }),
363
364        LogicalOp::OptionalMatch(om) => PhysicalOp::OptionalMatch(OptionalMatchExec {
365            input: om.input,
366            inner: om.inner,
367            new_vars: om.new_vars,
368        }),
369
370        LogicalOp::PathBuild(pb) => PhysicalOp::PathBuild(PathBuildExec {
371            input: pb.input,
372            output: pb.output,
373            node_vars: pb.node_vars,
374            rel_vars: pb.rel_vars,
375            shortest_path_all: pb.shortest_path_all,
376        }),
377
378        LogicalOp::CallSubquery(cs) => PhysicalOp::CallSubquery(CallSubqueryExec {
379            input: cs.input,
380            inner: cs.inner,
381            new_vars: cs.new_vars,
382        }),
383    }
384}
385
386fn lower_node_scan(scan: NodeScan) -> PhysicalOp {
387    if scan.labels.is_empty() {
388        PhysicalOp::NodeScan(NodeScanExec {
389            input: scan.input,
390            var: scan.var,
391        })
392    } else {
393        PhysicalOp::NodeByLabelScan(NodeByLabelScanExec {
394            input: scan.input,
395            var: scan.var,
396            labels: scan.labels,
397        })
398    }
399}
400
401fn collect_vars(expr: &ResolvedExpr) -> BTreeSet<VarId> {
402    let mut vars = BTreeSet::new();
403    collect_vars_inner(expr, &mut vars);
404    vars
405}
406
407/// Build every applicable index rewrite for a `Filter(NodeScan)` site,
408/// dropping ones whose extracted predicate is trivially true (would
409/// "match everything" — see [`is_tautological_*`] helpers). Each entry
410/// is a fully-formed `LogicalOp` ready to drop into `plan.nodes[input]`.
411fn collect_index_candidates(
412    scan: &NodeScan,
413    predicate: &ResolvedExpr,
414    stats: &GraphStats,
415) -> Vec<LogicalOp> {
416    let mut out = Vec::new();
417
418    if let Some((var, key, value)) = property_equality_for_var(predicate, scan.var) {
419        out.push(LogicalOp::NodeByPropertyScan(NodeByPropertyScan {
420            input: scan.input,
421            var,
422            labels: scan.labels.clone(),
423            key,
424            value,
425        }));
426    }
427
428    if let Some(bounds) = collect_range_bounds(predicate, scan.var) {
429        if !is_tautological_range(&bounds)
430            && first_simple_label(&scan.labels)
431                .is_some_and(|label| stats.has_node_range_index(label, &bounds.key))
432        {
433            out.push(LogicalOp::NodeByPropertyRangeScan(
434                NodeByPropertyRangeScan {
435                    input: scan.input,
436                    var: scan.var,
437                    labels: scan.labels.clone(),
438                    key: bounds.key,
439                    lo: bounds.lo,
440                    lo_inclusive: bounds.lo_inclusive,
441                    hi: bounds.hi,
442                    hi_inclusive: bounds.hi_inclusive,
443                },
444            ));
445        }
446    }
447
448    if let Some(candidate) = text_predicate_for_var(predicate, scan.var) {
449        if !is_tautological_text(&candidate)
450            && first_simple_label(&scan.labels)
451                .is_some_and(|label| stats.has_node_text_index(label, &candidate.key))
452        {
453            out.push(LogicalOp::NodeByTextScan(NodeByTextScan {
454                input: scan.input,
455                var: scan.var,
456                labels: scan.labels.clone(),
457                key: candidate.key,
458                predicate: candidate.predicate,
459                query: candidate.query,
460            }));
461        }
462    }
463
464    if let Some(candidate) = point_predicate_for_var(predicate, scan.var) {
465        if !is_tautological_point(&candidate)
466            && first_simple_label(&scan.labels)
467                .is_some_and(|label| stats.has_node_point_index(label, &candidate.key))
468        {
469            out.push(LogicalOp::NodeByPointScan(NodeByPointScan {
470                input: scan.input,
471                var: scan.var,
472                labels: scan.labels.clone(),
473                key: candidate.key,
474                predicate: candidate.predicate,
475            }));
476        }
477    }
478
479    out
480}
481
482/// Build every applicable rel-targeted index rewrite for a
483/// `Filter(Expand(NodeScan, …), pred-on-rel-var)` site. Mirrors
484/// [`collect_index_candidates`] for nodes; tautological predicates are
485/// dropped through the same `is_tautological_*` helpers.
486fn collect_rel_index_candidates(
487    expand: &Expand,
488    predicate: &ResolvedExpr,
489    rel_var: VarId,
490    input: Option<PlanNodeId>,
491    stats: &GraphStats,
492) -> Vec<LogicalOp> {
493    let mut out = Vec::new();
494
495    if let Some(candidate) = text_predicate_for_var(predicate, rel_var) {
496        if !is_tautological_text(&candidate)
497            && rel_types_have_index(&expand.types, |ty| {
498                stats.has_relationship_text_index(ty, &candidate.key)
499            })
500        {
501            out.push(LogicalOp::RelByTextScan(RelByTextScan {
502                input,
503                src: expand.src,
504                rel: rel_var,
505                dst: expand.dst,
506                types: expand.types.clone(),
507                direction: expand.direction,
508                key: candidate.key,
509                predicate: candidate.predicate,
510                query: candidate.query,
511            }));
512        }
513    }
514
515    if let Some(bounds) = collect_range_bounds(predicate, rel_var) {
516        if !is_tautological_range(&bounds)
517            && rel_types_have_index(&expand.types, |ty| {
518                stats.has_relationship_range_index(ty, &bounds.key)
519            })
520        {
521            out.push(LogicalOp::RelByPropertyRangeScan(RelByPropertyRangeScan {
522                input,
523                src: expand.src,
524                rel: rel_var,
525                dst: expand.dst,
526                types: expand.types.clone(),
527                direction: expand.direction,
528                key: bounds.key,
529                lo: bounds.lo,
530                lo_inclusive: bounds.lo_inclusive,
531                hi: bounds.hi,
532                hi_inclusive: bounds.hi_inclusive,
533            }));
534        }
535    }
536
537    if let Some(candidate) = point_predicate_for_var(predicate, rel_var) {
538        if !is_tautological_point(&candidate)
539            && rel_types_have_index(&expand.types, |ty| {
540                stats.has_relationship_point_index(ty, &candidate.key)
541            })
542        {
543            out.push(LogicalOp::RelByPointScan(RelByPointScan {
544                input,
545                src: expand.src,
546                rel: rel_var,
547                dst: expand.dst,
548                types: expand.types.clone(),
549                direction: expand.direction,
550                key: candidate.key,
551                predicate: candidate.predicate,
552            }));
553        }
554    }
555
556    out
557}
558
559fn rel_types_have_index<F>(types: &[String], mut has_index: F) -> bool
560where
561    F: FnMut(&str) -> bool,
562{
563    !types.is_empty() && types.iter().all(|ty| has_index(ty))
564}
565
566/// Choose the cheapest replacement for the original `Filter(NodeScan)`
567/// input among `candidates`, breaking ties by the caller-provided
568/// collection order so behaviour is deterministic. Returns
569/// `None` when no candidate is strictly cheaper than the original — in
570/// that case the caller leaves the plan unchanged.
571fn pick_best_candidate(
572    original: &LogicalOp,
573    candidates: Vec<LogicalOp>,
574    stats: &GraphStats,
575) -> Option<LogicalOp> {
576    if candidates.is_empty() {
577        return None;
578    }
579
580    let baseline = score_logical_op(original, stats);
581
582    let mut best: Option<(LogicalOp, Option<u64>)> = None;
583    for candidate in candidates {
584        let score = score_logical_op(&candidate, stats);
585        if !improves_over(score, baseline) {
586            continue;
587        }
588        let take = match &best {
589            None => true,
590            Some((_, current_best)) => is_cheaper(score, *current_best),
591        };
592        if take {
593            best = Some((candidate, score));
594        }
595    }
596
597    best.map(|(op, _)| op)
598}
599
600/// `true` when committing a candidate with `score` is at least as good
601/// as keeping the original (`baseline`). With unknown stats the
602/// optimizer keeps its pre-cost-model behaviour: a `None`/`None`
603/// comparison still commits already-collected candidates. Catalog
604/// checks happen before this point for range/text/point operators.
605fn improves_over(score: Option<u64>, baseline: Option<u64>) -> bool {
606    match (score, baseline) {
607        (Some(s), Some(b)) => s <= b,
608        (Some(_), None) => true,
609        (None, Some(_)) => false,
610        (None, None) => true,
611    }
612}
613
614fn is_cheaper(score: Option<u64>, current_best: Option<u64>) -> bool {
615    match (score, current_best) {
616        (Some(s), Some(b)) => s < b,
617        (Some(_), None) => true,
618        (None, _) => false,
619    }
620}
621
622/// Estimated row count produced by `op`. `None` means "no information"
623/// — typically because the relevant labels or property are not in the
624/// stats snapshot. Mirrors the per-operator estimates that `EXPLAIN`
625/// surfaces in [`crate::plan_tree::PlanTreeNode::estimated_rows`], so
626/// the optimizer's pick agrees with what users see in the plan tree.
627fn score_logical_op(op: &LogicalOp, stats: &GraphStats) -> Option<u64> {
628    match op {
629        LogicalOp::NodeScan(scan) => match label_estimate(&scan.labels, stats) {
630            Some(rows) => Some(rows),
631            None if scan.labels.is_empty() => Some(stats.node_count as u64),
632            None => None,
633        },
634        LogicalOp::NodeByPropertyScan(scan) => {
635            let label = first_simple_label(&scan.labels)?;
636            stats.estimate_node_property_equality(label, &scan.key)
637        }
638        LogicalOp::NodeByPropertyRangeScan(scan) => {
639            // Conservative one-third selectivity: a one-sided range
640            // typically narrows by less than half; a two-sided range
641            // narrows further. Better than full-label scan for any
642            // useful range, never worse than `label_count`.
643            let base = label_estimate(&scan.labels, stats)?;
644            let denom = match (scan.lo.is_some(), scan.hi.is_some()) {
645                (true, true) => 4,
646                _ => 3,
647            };
648            Some(base.div_ceil(denom))
649        }
650        LogicalOp::NodeByTextScan(scan) => {
651            let base = label_estimate(&scan.labels, stats)?;
652            // Prefix/suffix probes typically narrow more than CONTAINS.
653            let denom = match scan.predicate {
654                TextPredicate::StartsWith | TextPredicate::EndsWith => 4,
655                TextPredicate::Contains => 2,
656            };
657            Some(base.div_ceil(denom))
658        }
659        LogicalOp::NodeByPointScan(scan) => {
660            let base = label_estimate(&scan.labels, stats)?;
661            // Spatial probes: bbox/distance usually returns a small
662            // fraction of the labelled set.
663            Some(base.div_ceil(5))
664        }
665        LogicalOp::Filter(_) => None,
666        LogicalOp::Expand(expand) => {
667            // Used as the baseline when evaluating rel-index rewrites.
668            // Without per-edge histograms we approximate edges-of-type
669            // by `relationship_type_count`, falling back to the global
670            // relationship total when no type is named.
671            let count = if expand.types.is_empty() {
672                stats.relationship_count as u64
673            } else {
674                let mut total: u64 = 0;
675                for ty in &expand.types {
676                    total = total.saturating_add(stats.relationship_type_count(ty)?);
677                }
678                total
679            };
680            // Undirected expansion produces both orientations.
681            Some(match expand.direction {
682                lora_ast::Direction::Undirected => count.saturating_mul(2),
683                _ => count,
684            })
685        }
686        LogicalOp::RelByPropertyRangeScan(scan) => {
687            let base = rel_type_estimate(&scan.types, stats)?;
688            let denom = match (scan.lo.is_some(), scan.hi.is_some()) {
689                (true, true) => 4,
690                _ => 3,
691            };
692            let est = base.div_ceil(denom);
693            Some(match scan.direction {
694                lora_ast::Direction::Undirected => est.saturating_mul(2),
695                _ => est,
696            })
697        }
698        LogicalOp::RelByTextScan(scan) => {
699            let base = rel_type_estimate(&scan.types, stats)?;
700            let denom = match scan.predicate {
701                TextPredicate::StartsWith | TextPredicate::EndsWith => 4,
702                TextPredicate::Contains => 2,
703            };
704            let est = base.div_ceil(denom);
705            Some(match scan.direction {
706                lora_ast::Direction::Undirected => est.saturating_mul(2),
707                _ => est,
708            })
709        }
710        LogicalOp::RelByPointScan(scan) => {
711            let base = rel_type_estimate(&scan.types, stats)?;
712            let est = base.div_ceil(5);
713            Some(match scan.direction {
714                lora_ast::Direction::Undirected => est.saturating_mul(2),
715                _ => est,
716            })
717        }
718        _ => None,
719    }
720}
721
722fn rel_type_estimate(types: &[String], stats: &GraphStats) -> Option<u64> {
723    if types.is_empty() {
724        return Some(stats.relationship_count as u64);
725    }
726    let mut total: u64 = 0;
727    for ty in types {
728        total = total.saturating_add(stats.relationship_type_count(ty)?);
729    }
730    Some(total)
731}
732
733/// Return the count of nodes covered by a `labels` group, taking the
734/// first DNF disjunction's first literal. Mirrors `labels_estimate`
735/// from `lora-database/src/database/explain.rs` — keeping the two in
736/// sync ensures `EXPLAIN` and the optimizer agree.
737fn label_estimate(labels: &[Vec<String>], stats: &GraphStats) -> Option<u64> {
738    let label = first_simple_label(labels)?;
739    stats.label_count(label)
740}
741
742fn first_simple_label(labels: &[Vec<String>]) -> Option<&str> {
743    labels.first()?.first().map(String::as_str)
744}
745
746fn is_tautological_range(bounds: &RangeBounds) -> bool {
747    let lo_open = match (&bounds.lo, bounds.lo_inclusive) {
748        (None, _) => true,
749        (Some(expr), false) => matches!(
750            expr,
751            ResolvedExpr::Literal(LiteralValue::Integer(v)) if *v == i64::MIN
752        ),
753        (Some(_), true) => false,
754    };
755    let hi_open = match (&bounds.hi, bounds.hi_inclusive) {
756        (None, _) => true,
757        (Some(expr), false) => matches!(
758            expr,
759            ResolvedExpr::Literal(LiteralValue::Integer(v)) if *v == i64::MAX
760        ),
761        (Some(_), true) => false,
762    };
763    lo_open && hi_open
764}
765
766fn is_tautological_text(candidate: &TextCandidate) -> bool {
767    matches!(
768        &candidate.query,
769        ResolvedExpr::Literal(LiteralValue::String(s)) if s.is_empty()
770    )
771}
772
773fn is_tautological_point(candidate: &PointCandidate) -> bool {
774    match &candidate.predicate {
775        PointPredicate::WithinBBox {
776            lower_left,
777            upper_right,
778        } => is_world_bbox(lower_left, upper_right),
779        PointPredicate::WithinDistance { .. } => false,
780    }
781}
782
783/// `{longitude: -180, latitude: -90}::POINT` to
784/// `{longitude: 180, latitude: 90}::POINT` covers every WGS-84 point.
785/// Detecting the literal form avoids a trigram lookup that would yield
786/// every indexed row only to be re-filtered to the same set.
787fn is_world_bbox(lower_left: &ResolvedExpr, upper_right: &ResolvedExpr) -> bool {
788    /// Cypher parses `-180` as `Unary{Neg, Literal(180)}`, not as a
789    /// negative integer literal — peel one such layer so the
790    /// world-bbox detection works on natural query forms.
791    fn const_number(expr: &ResolvedExpr) -> Option<f64> {
792        match expr {
793            ResolvedExpr::Literal(LiteralValue::Float(v)) => Some(*v),
794            ResolvedExpr::Literal(LiteralValue::Integer(v)) => Some(*v as f64),
795            ResolvedExpr::Unary {
796                op: lora_ast::UnaryOp::Neg,
797                expr,
798            } => const_number(expr).map(|v| -v),
799            ResolvedExpr::Unary {
800                op: lora_ast::UnaryOp::Pos,
801                expr,
802            } => const_number(expr),
803            _ => None,
804        }
805    }
806
807    fn point_lon_lat(expr: &ResolvedExpr) -> Option<(f64, f64)> {
808        let items = point_literal_map(expr)?;
809        let mut lon: Option<f64> = None;
810        let mut lat: Option<f64> = None;
811        for (key, value) in items {
812            let n = const_number(value)?;
813            match key.as_str() {
814                "longitude" | "x" => lon = Some(n),
815                "latitude" | "y" => lat = Some(n),
816                _ => {}
817            }
818        }
819        Some((lon?, lat?))
820    }
821
822    let Some((ll_lon, ll_lat)) = point_lon_lat(lower_left) else {
823        return false;
824    };
825    let Some((ur_lon, ur_lat)) = point_lon_lat(upper_right) else {
826        return false;
827    };
828    ll_lon <= -180.0 && ll_lat <= -90.0 && ur_lon >= 180.0 && ur_lat >= 90.0
829}
830
831fn point_literal_map(expr: &ResolvedExpr) -> Option<&Vec<(String, ResolvedExpr)>> {
832    let ResolvedExpr::Function { function, args, .. } = expr else {
833        return None;
834    };
835    if function.eq_ignore_ascii_case("geo.point") && args.len() == 1 {
836        let ResolvedExpr::Map(items) = &args[0] else {
837            return None;
838        };
839        return Some(items);
840    }
841    if function.eq_ignore_ascii_case("cast.to") && args.len() == 2 {
842        let ResolvedExpr::Map(items) = &args[0] else {
843            return None;
844        };
845        let ResolvedExpr::Literal(LiteralValue::TypeName(target)) = &args[1] else {
846            return None;
847        };
848        if target.eq_ignore_ascii_case("POINT") {
849            return Some(items);
850        }
851    }
852    None
853}
854
855struct RangeBounds {
856    key: String,
857    lo: Option<ResolvedExpr>,
858    lo_inclusive: bool,
859    hi: Option<ResolvedExpr>,
860    hi_inclusive: bool,
861}
862
863/// Walk an AND-tree and collect any `var.prop CMP literal` bounds.
864/// Returns `None` if no comparison touches `var.prop` for a single
865/// property key — we don't try to combine multi-property bounds in v1.
866fn collect_range_bounds(predicate: &ResolvedExpr, var: VarId) -> Option<RangeBounds> {
867    let mut key: Option<String> = None;
868    let mut lo: Option<ResolvedExpr> = None;
869    let mut lo_inclusive = false;
870    let mut hi: Option<ResolvedExpr> = None;
871    let mut hi_inclusive = false;
872    let mut any = false;
873
874    walk_and_for_range(
875        predicate,
876        var,
877        &mut |found_key, side, value, inclusive| match side {
878            RangeSide::Lower => {
879                if key.as_deref().map(|k| k != found_key).unwrap_or(false) {
880                    return;
881                }
882                key = Some(found_key.to_string());
883                if lo
884                    .as_ref()
885                    .map(|current| lower_bound_is_tighter(&value, inclusive, current, lo_inclusive))
886                    .unwrap_or(true)
887                {
888                    lo = Some(value);
889                    lo_inclusive = inclusive;
890                }
891                any = true;
892            }
893            RangeSide::Upper => {
894                if key.as_deref().map(|k| k != found_key).unwrap_or(false) {
895                    return;
896                }
897                key = Some(found_key.to_string());
898                if hi
899                    .as_ref()
900                    .map(|current| upper_bound_is_tighter(&value, inclusive, current, hi_inclusive))
901                    .unwrap_or(true)
902                {
903                    hi = Some(value);
904                    hi_inclusive = inclusive;
905                }
906                any = true;
907            }
908        },
909    );
910
911    if !any {
912        return None;
913    }
914    Some(RangeBounds {
915        key: key?,
916        lo,
917        lo_inclusive,
918        hi,
919        hi_inclusive,
920    })
921}
922
923#[derive(Clone, Copy)]
924enum RangeSide {
925    Lower,
926    Upper,
927}
928
929fn lower_bound_is_tighter(
930    candidate: &ResolvedExpr,
931    candidate_inclusive: bool,
932    current: &ResolvedExpr,
933    current_inclusive: bool,
934) -> bool {
935    match compare_literal_bounds(candidate, current) {
936        Some(std::cmp::Ordering::Greater) => true,
937        Some(std::cmp::Ordering::Equal) => !candidate_inclusive && current_inclusive,
938        _ => false,
939    }
940}
941
942fn upper_bound_is_tighter(
943    candidate: &ResolvedExpr,
944    candidate_inclusive: bool,
945    current: &ResolvedExpr,
946    current_inclusive: bool,
947) -> bool {
948    match compare_literal_bounds(candidate, current) {
949        Some(std::cmp::Ordering::Less) => true,
950        Some(std::cmp::Ordering::Equal) => !candidate_inclusive && current_inclusive,
951        _ => false,
952    }
953}
954
955fn compare_literal_bounds(lhs: &ResolvedExpr, rhs: &ResolvedExpr) -> Option<std::cmp::Ordering> {
956    match (literal_number(lhs), literal_number(rhs)) {
957        (Some(a), Some(b)) => return a.partial_cmp(&b),
958        (Some(_), None) | (None, Some(_)) => return None,
959        (None, None) => {}
960    }
961
962    match (lhs, rhs) {
963        (
964            ResolvedExpr::Literal(LiteralValue::String(a)),
965            ResolvedExpr::Literal(LiteralValue::String(b)),
966        ) => Some(a.cmp(b)),
967        _ => None,
968    }
969}
970
971fn literal_number(expr: &ResolvedExpr) -> Option<f64> {
972    match expr {
973        ResolvedExpr::Literal(LiteralValue::Integer(v)) => Some(*v as f64),
974        ResolvedExpr::Literal(LiteralValue::Float(v)) => Some(*v),
975        ResolvedExpr::Unary {
976            op: lora_ast::UnaryOp::Neg,
977            expr,
978        } => literal_number(expr).map(|v| -v),
979        ResolvedExpr::Unary {
980            op: lora_ast::UnaryOp::Pos,
981            expr,
982        } => literal_number(expr),
983        _ => None,
984    }
985}
986
987fn walk_and_for_range<F>(predicate: &ResolvedExpr, var: VarId, visit: &mut F)
988where
989    F: FnMut(&str, RangeSide, ResolvedExpr, bool),
990{
991    if let ResolvedExpr::Binary {
992        lhs,
993        op: BinaryOp::And,
994        rhs,
995    } = predicate
996    {
997        walk_and_for_range(lhs, var, visit);
998        walk_and_for_range(rhs, var, visit);
999        return;
1000    }
1001
1002    let ResolvedExpr::Binary { lhs, op, rhs } = predicate else {
1003        return;
1004    };
1005
1006    let (side, inclusive) = match op {
1007        BinaryOp::Gt => (RangeSide::Lower, false),
1008        BinaryOp::Ge => (RangeSide::Lower, true),
1009        BinaryOp::Lt => (RangeSide::Upper, false),
1010        BinaryOp::Le => (RangeSide::Upper, true),
1011        _ => return,
1012    };
1013
1014    if let Some(key) = property_access_for_var(lhs, var) {
1015        if !collect_vars(rhs).contains(&var) {
1016            visit(&key, side, (**rhs).clone(), inclusive);
1017            return;
1018        }
1019    }
1020    if let Some(key) = property_access_for_var(rhs, var) {
1021        if !collect_vars(lhs).contains(&var) {
1022            // Mirror `value CMP var.prop` to `var.prop FLIPPED_CMP value`.
1023            let flipped = match side {
1024                RangeSide::Lower => RangeSide::Upper,
1025                RangeSide::Upper => RangeSide::Lower,
1026            };
1027            visit(&key, flipped, (**lhs).clone(), inclusive);
1028        }
1029    }
1030}
1031
1032struct TextCandidate {
1033    key: String,
1034    predicate: TextPredicate,
1035    query: ResolvedExpr,
1036}
1037
1038struct PointCandidate {
1039    key: String,
1040    predicate: PointPredicate,
1041}
1042
1043fn point_predicate_for_var(predicate: &ResolvedExpr, var: VarId) -> Option<PointCandidate> {
1044    if let ResolvedExpr::Binary {
1045        lhs,
1046        op: BinaryOp::And,
1047        rhs,
1048    } = predicate
1049    {
1050        return point_predicate_for_var(lhs, var).or_else(|| point_predicate_for_var(rhs, var));
1051    }
1052
1053    // geo.within_bbox(n.prop, ll, ur)
1054    if let ResolvedExpr::Function { function, args, .. } = predicate {
1055        if function.eq_ignore_ascii_case("geo.within_bbox") && args.len() == 3 {
1056            if let Some(key) = property_access_for_var(&args[0], var) {
1057                if !collect_vars(&args[1]).contains(&var) && !collect_vars(&args[2]).contains(&var)
1058                {
1059                    return Some(PointCandidate {
1060                        key,
1061                        predicate: PointPredicate::WithinBBox {
1062                            lower_left: args[1].clone(),
1063                            upper_right: args[2].clone(),
1064                        },
1065                    });
1066                }
1067            }
1068        }
1069    }
1070
1071    // point.distance(n.prop, c) OP d  (where OP is <, <=)
1072    let ResolvedExpr::Binary { lhs, op, rhs } = predicate else {
1073        return None;
1074    };
1075    let inclusive = match op {
1076        BinaryOp::Le => true,
1077        BinaryOp::Lt => false,
1078        // Symmetric form: d >= point.distance(n.prop, c)
1079        BinaryOp::Ge => true,
1080        BinaryOp::Gt => false,
1081        _ => return None,
1082    };
1083
1084    let (call_side, scalar_side) = match op {
1085        BinaryOp::Le | BinaryOp::Lt => ((**lhs).clone(), (**rhs).clone()),
1086        BinaryOp::Ge | BinaryOp::Gt => ((**rhs).clone(), (**lhs).clone()),
1087        _ => return None,
1088    };
1089
1090    let ResolvedExpr::Function { function, args, .. } = &call_side else {
1091        return None;
1092    };
1093    if !function.eq_ignore_ascii_case("geo.distance") {
1094        return None;
1095    }
1096    if args.len() != 2 {
1097        return None;
1098    }
1099    let key = property_access_for_var(&args[0], var)?;
1100    if collect_vars(&args[1]).contains(&var) || collect_vars(&scalar_side).contains(&var) {
1101        return None;
1102    }
1103    Some(PointCandidate {
1104        key,
1105        predicate: PointPredicate::WithinDistance {
1106            center: args[1].clone(),
1107            max_distance: scalar_side,
1108            inclusive,
1109        },
1110    })
1111}
1112
1113fn text_predicate_for_var(predicate: &ResolvedExpr, var: VarId) -> Option<TextCandidate> {
1114    let ResolvedExpr::Binary { lhs, op, rhs } = predicate else {
1115        return None;
1116    };
1117
1118    if matches!(op, BinaryOp::And) {
1119        return text_predicate_for_var(lhs, var).or_else(|| text_predicate_for_var(rhs, var));
1120    }
1121
1122    let kind = match op {
1123        BinaryOp::StartsWith => TextPredicate::StartsWith,
1124        BinaryOp::EndsWith => TextPredicate::EndsWith,
1125        BinaryOp::Contains => TextPredicate::Contains,
1126        _ => return None,
1127    };
1128
1129    let key = property_access_for_var(lhs, var)?;
1130    if collect_vars(rhs).contains(&var) {
1131        return None;
1132    }
1133    Some(TextCandidate {
1134        key,
1135        predicate: kind,
1136        query: (**rhs).clone(),
1137    })
1138}
1139
1140fn property_equality_for_var(
1141    predicate: &ResolvedExpr,
1142    var: VarId,
1143) -> Option<(VarId, String, ResolvedExpr)> {
1144    let ResolvedExpr::Binary { lhs, op, rhs } = predicate else {
1145        return None;
1146    };
1147
1148    if matches!(op, BinaryOp::And) {
1149        return property_equality_for_var(lhs, var).or_else(|| property_equality_for_var(rhs, var));
1150    }
1151
1152    if !matches!(op, BinaryOp::Eq) {
1153        return None;
1154    }
1155
1156    property_access_for_var(lhs, var)
1157        .filter(|_| !collect_vars(rhs).contains(&var))
1158        .map(|key| (var, key, (**rhs).clone()))
1159        .or_else(|| {
1160            property_access_for_var(rhs, var)
1161                .filter(|_| !collect_vars(lhs).contains(&var))
1162                .map(|key| (var, key, (**lhs).clone()))
1163        })
1164}
1165
1166fn static_limit_bound(limit: &Limit) -> Option<usize> {
1167    let limit_rows = match &limit.limit {
1168        Some(expr) => static_non_negative_usize(expr)?,
1169        None => return None,
1170    };
1171    let skip_rows = limit
1172        .skip
1173        .as_ref()
1174        .and_then(static_non_negative_usize)
1175        .unwrap_or(0);
1176    Some(skip_rows.saturating_add(limit_rows))
1177}
1178
1179fn limit_sort_bound(op: &LogicalOp) -> Option<(PlanNodeId, usize)> {
1180    let LogicalOp::Limit(limit) = op else {
1181        return None;
1182    };
1183
1184    static_limit_bound(limit).map(|bound| (limit.input, bound))
1185}
1186
1187fn sort_op_mut(op: &mut LogicalOp) -> Option<&mut Sort> {
1188    match op {
1189        LogicalOp::Sort(sort) => Some(sort),
1190        _ => None,
1191    }
1192}
1193
1194fn merge_top_k_bound(current: Option<usize>, bound: usize) -> Option<usize> {
1195    Some(current.map(|current| current.min(bound)).unwrap_or(bound))
1196}
1197
1198fn static_non_negative_usize(expr: &ResolvedExpr) -> Option<usize> {
1199    match expr {
1200        ResolvedExpr::Literal(LiteralValue::Integer(value)) => {
1201            Some((*value).max(0).try_into().unwrap_or(usize::MAX))
1202        }
1203        _ => None,
1204    }
1205}
1206
1207fn property_access_for_var(expr: &ResolvedExpr, var: VarId) -> Option<String> {
1208    match expr {
1209        ResolvedExpr::Property { expr, property } => match &**expr {
1210            ResolvedExpr::Variable(v) if *v == var => Some(property.clone()),
1211            _ => None,
1212        },
1213        _ => None,
1214    }
1215}
1216
1217fn collect_vars_inner(expr: &ResolvedExpr, out: &mut BTreeSet<VarId>) {
1218    match expr {
1219        ResolvedExpr::Variable(v) => {
1220            out.insert(*v);
1221        }
1222        ResolvedExpr::Property { expr, .. } => collect_vars_inner(expr, out),
1223        ResolvedExpr::Binary { lhs, rhs, .. } => {
1224            collect_vars_inner(lhs, out);
1225            collect_vars_inner(rhs, out);
1226        }
1227        ResolvedExpr::Unary { expr, .. } => collect_vars_inner(expr, out),
1228        ResolvedExpr::Function { args, .. } => {
1229            for arg in args {
1230                collect_vars_inner(arg, out);
1231            }
1232        }
1233        ResolvedExpr::List(items) => {
1234            for item in items {
1235                collect_vars_inner(item, out);
1236            }
1237        }
1238        ResolvedExpr::Map(items) => {
1239            for (_, v) in items {
1240                collect_vars_inner(v, out);
1241            }
1242        }
1243        ResolvedExpr::Case {
1244            input,
1245            alternatives,
1246            else_expr,
1247        } => {
1248            if let Some(e) = input {
1249                collect_vars_inner(e, out);
1250            }
1251            for (w, t) in alternatives {
1252                collect_vars_inner(w, out);
1253                collect_vars_inner(t, out);
1254            }
1255            if let Some(e) = else_expr {
1256                collect_vars_inner(e, out);
1257            }
1258        }
1259        ResolvedExpr::ListPredicate {
1260            variable,
1261            list,
1262            predicate,
1263            ..
1264        } => {
1265            out.insert(*variable);
1266            collect_vars_inner(list, out);
1267            collect_vars_inner(predicate, out);
1268        }
1269        ResolvedExpr::ListComprehension {
1270            variable,
1271            list,
1272            filter,
1273            map_expr,
1274            ..
1275        } => {
1276            out.insert(*variable);
1277            collect_vars_inner(list, out);
1278            if let Some(f) = filter {
1279                collect_vars_inner(f, out);
1280            }
1281            if let Some(m) = map_expr {
1282                collect_vars_inner(m, out);
1283            }
1284        }
1285        ResolvedExpr::Reduce {
1286            accumulator,
1287            init,
1288            variable,
1289            list,
1290            expr,
1291            ..
1292        } => {
1293            out.insert(*accumulator);
1294            out.insert(*variable);
1295            collect_vars_inner(init, out);
1296            collect_vars_inner(list, out);
1297            collect_vars_inner(expr, out);
1298        }
1299        ResolvedExpr::Index { expr, index } => {
1300            collect_vars_inner(expr, out);
1301            collect_vars_inner(index, out);
1302        }
1303        ResolvedExpr::Slice { expr, from, to } => {
1304            collect_vars_inner(expr, out);
1305            if let Some(f) = from {
1306                collect_vars_inner(f, out);
1307            }
1308            if let Some(t) = to {
1309                collect_vars_inner(t, out);
1310            }
1311        }
1312        ResolvedExpr::MapProjection { base, selectors } => {
1313            collect_vars_inner(base, out);
1314            for sel in selectors {
1315                if let lora_analyzer::ResolvedMapSelector::Literal(_, e) = sel {
1316                    collect_vars_inner(e, out);
1317                }
1318            }
1319        }
1320        _ => {}
1321    }
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326    use super::*;
1327    use lora_store::GraphStats;
1328
1329    fn stats_with_label(label: &str, total: usize, distinct: Option<usize>) -> GraphStats {
1330        let mut s = GraphStats {
1331            node_count: total,
1332            ..Default::default()
1333        };
1334        s.nodes_by_label.insert(label.to_string(), total);
1335        if let Some(d) = distinct {
1336            s.node_distinct_values
1337                .insert((label.to_string(), "id".to_string()), d);
1338        }
1339        s
1340    }
1341
1342    fn person_labels() -> Vec<Vec<String>> {
1343        vec![vec!["Person".to_string()]]
1344    }
1345
1346    fn lit_int(v: i64) -> ResolvedExpr {
1347        ResolvedExpr::Literal(LiteralValue::Integer(v))
1348    }
1349
1350    fn lit_str(s: &str) -> ResolvedExpr {
1351        ResolvedExpr::Literal(LiteralValue::String(s.to_string()))
1352    }
1353
1354    fn lit_float(v: f64) -> ResolvedExpr {
1355        ResolvedExpr::Literal(LiteralValue::Float(v))
1356    }
1357
1358    fn point_lonlat(lon: f64, lat: f64) -> ResolvedExpr {
1359        ResolvedExpr::Function {
1360            function: lora_analyzer::FunctionId::builtin("cast.to")
1361                .expect("cast.to builtin exists"),
1362            distinct: false,
1363            args: vec![
1364                ResolvedExpr::Map(vec![
1365                    ("longitude".to_string(), lit_float(lon)),
1366                    ("latitude".to_string(), lit_float(lat)),
1367                ]),
1368                ResolvedExpr::Literal(LiteralValue::TypeName("POINT".to_string())),
1369            ],
1370        }
1371    }
1372
1373    // ---------- score_logical_op ----------
1374
1375    #[test]
1376    fn score_label_scan_returns_label_count() {
1377        let stats = stats_with_label("Person", 1_000, None);
1378        let op = LogicalOp::NodeScan(NodeScan {
1379            input: None,
1380            var: VarId(0),
1381            labels: person_labels(),
1382        });
1383        assert_eq!(score_logical_op(&op, &stats), Some(1_000));
1384    }
1385
1386    #[test]
1387    fn score_property_scan_uses_distinct() {
1388        // 1000 nodes, 100 distinct values for `id`: ~10 per value.
1389        let stats = stats_with_label("Person", 1_000, Some(100));
1390        let op = LogicalOp::NodeByPropertyScan(NodeByPropertyScan {
1391            input: None,
1392            var: VarId(0),
1393            labels: person_labels(),
1394            key: "id".to_string(),
1395            value: lit_int(7),
1396        });
1397        assert_eq!(score_logical_op(&op, &stats), Some(10));
1398    }
1399
1400    #[test]
1401    fn score_property_scan_high_distinct_beats_label_scan() {
1402        // distinct == total → uniform-distribution heuristic gives 1
1403        // estimated row, well below the 100-row label scan.
1404        let stats = stats_with_label("Person", 100, Some(100));
1405        let label_score = score_logical_op(
1406            &LogicalOp::NodeScan(NodeScan {
1407                input: None,
1408                var: VarId(0),
1409                labels: person_labels(),
1410            }),
1411            &stats,
1412        );
1413        let property_score = score_logical_op(
1414            &LogicalOp::NodeByPropertyScan(NodeByPropertyScan {
1415                input: None,
1416                var: VarId(0),
1417                labels: person_labels(),
1418                key: "id".to_string(),
1419                value: lit_int(7),
1420            }),
1421            &stats,
1422        );
1423        assert!(property_score < label_score);
1424    }
1425
1426    #[test]
1427    fn score_returns_none_without_label_stats() {
1428        // Empty stats: every estimator should fail open so the optimizer
1429        // can fall back to the legacy "commit any matching rewrite"
1430        // behaviour through `improves_over(None, None)`.
1431        let stats = GraphStats::default();
1432        let op = LogicalOp::NodeScan(NodeScan {
1433            input: None,
1434            var: VarId(0),
1435            labels: person_labels(),
1436        });
1437        assert_eq!(score_logical_op(&op, &stats), None);
1438    }
1439
1440    // ---------- improves_over ----------
1441
1442    #[test]
1443    fn improves_over_legacy_fallback_when_both_unknown() {
1444        // `(None, None)` must resolve to "commit the rewrite" — that is
1445        // the contract for environments without stats.
1446        assert!(improves_over(None, None));
1447    }
1448
1449    #[test]
1450    fn improves_over_keeps_baseline_when_candidate_unknown() {
1451        assert!(!improves_over(None, Some(10)));
1452    }
1453
1454    #[test]
1455    fn improves_over_strictly_better_or_equal_wins() {
1456        assert!(improves_over(Some(5), Some(10)));
1457        assert!(improves_over(Some(10), Some(10)));
1458        assert!(!improves_over(Some(11), Some(10)));
1459    }
1460
1461    // ---------- pick_best_candidate ----------
1462
1463    #[test]
1464    fn pick_best_candidate_picks_lowest_score() {
1465        let stats = stats_with_label("Person", 1_200, Some(100));
1466        let original = LogicalOp::NodeScan(NodeScan {
1467            input: None,
1468            var: VarId(0),
1469            labels: person_labels(),
1470        });
1471        // property scan ~12 rows (1200 / 100), range scan ~400 rows.
1472        let candidates = vec![
1473            LogicalOp::NodeByPropertyRangeScan(NodeByPropertyRangeScan {
1474                input: None,
1475                var: VarId(0),
1476                labels: person_labels(),
1477                key: "age".to_string(),
1478                lo: Some(lit_int(30)),
1479                lo_inclusive: false,
1480                hi: None,
1481                hi_inclusive: false,
1482            }),
1483            LogicalOp::NodeByPropertyScan(NodeByPropertyScan {
1484                input: None,
1485                var: VarId(0),
1486                labels: person_labels(),
1487                key: "id".to_string(),
1488                value: lit_int(7),
1489            }),
1490        ];
1491        let pick = pick_best_candidate(&original, candidates, &stats).expect("expected a pick");
1492        assert!(matches!(pick, LogicalOp::NodeByPropertyScan(_)));
1493    }
1494
1495    #[test]
1496    fn pick_best_candidate_returns_none_when_no_candidate_improves() {
1497        // Stats put baseline at 1 row (1 node, distinct=1) and the
1498        // property scan also at 1 — but no rewrite improves, and we'd
1499        // still commit the candidate because of the s<=b fallback.
1500        // To prove we *can* return None, give the candidate a higher
1501        // score than baseline.
1502        let mut stats = stats_with_label("Person", 1, None);
1503        // 1 Person, 1 distinct → property scan = 1, label scan = 1.
1504        // Add a rare label so label_estimate gives 1 but the candidate
1505        // has no distinct entry → unknown score → can't improve.
1506        stats.nodes_by_label.insert("Tiny".to_string(), 1);
1507        let original = LogicalOp::NodeScan(NodeScan {
1508            input: None,
1509            var: VarId(0),
1510            labels: vec![vec!["Tiny".to_string()]],
1511        });
1512        // Candidate references a different label → label_estimate fails
1513        // → score is None. Baseline is Some(1). improves_over(None,
1514        // Some(1)) = false → no rewrite.
1515        let candidates = vec![LogicalOp::NodeByPropertyScan(NodeByPropertyScan {
1516            input: None,
1517            var: VarId(0),
1518            labels: vec![vec!["Missing".to_string()]],
1519            key: "id".to_string(),
1520            value: lit_int(7),
1521        })];
1522        assert!(pick_best_candidate(&original, candidates, &stats).is_none());
1523    }
1524
1525    // ---------- tautology guards ----------
1526
1527    #[test]
1528    fn unbounded_low_range_is_tautological() {
1529        let bounds = RangeBounds {
1530            key: "age".to_string(),
1531            lo: Some(lit_int(i64::MIN)),
1532            lo_inclusive: false,
1533            hi: None,
1534            hi_inclusive: false,
1535        };
1536        assert!(is_tautological_range(&bounds));
1537    }
1538
1539    #[test]
1540    fn unbounded_high_range_is_tautological() {
1541        let bounds = RangeBounds {
1542            key: "age".to_string(),
1543            lo: None,
1544            lo_inclusive: false,
1545            hi: Some(lit_int(i64::MAX)),
1546            hi_inclusive: false,
1547        };
1548        assert!(is_tautological_range(&bounds));
1549    }
1550
1551    #[test]
1552    fn doubly_unbounded_range_is_tautological() {
1553        let bounds = RangeBounds {
1554            key: "age".to_string(),
1555            lo: Some(lit_int(i64::MIN)),
1556            lo_inclusive: false,
1557            hi: Some(lit_int(i64::MAX)),
1558            hi_inclusive: false,
1559        };
1560        assert!(is_tautological_range(&bounds));
1561    }
1562
1563    #[test]
1564    fn ordinary_range_is_not_tautological() {
1565        let bounds = RangeBounds {
1566            key: "age".to_string(),
1567            lo: Some(lit_int(0)),
1568            lo_inclusive: false,
1569            hi: Some(lit_int(100)),
1570            hi_inclusive: false,
1571        };
1572        assert!(!is_tautological_range(&bounds));
1573    }
1574
1575    #[test]
1576    fn empty_string_starts_with_is_tautological() {
1577        let candidate = TextCandidate {
1578            key: "name".to_string(),
1579            predicate: TextPredicate::StartsWith,
1580            query: lit_str(""),
1581        };
1582        assert!(is_tautological_text(&candidate));
1583    }
1584
1585    #[test]
1586    fn nonempty_string_starts_with_is_not_tautological() {
1587        let candidate = TextCandidate {
1588            key: "name".to_string(),
1589            predicate: TextPredicate::StartsWith,
1590            query: lit_str("A"),
1591        };
1592        assert!(!is_tautological_text(&candidate));
1593    }
1594
1595    #[test]
1596    fn world_bbox_is_tautological() {
1597        let candidate = PointCandidate {
1598            key: "loc".to_string(),
1599            predicate: PointPredicate::WithinBBox {
1600                lower_left: point_lonlat(-180.0, -90.0),
1601                upper_right: point_lonlat(180.0, 90.0),
1602            },
1603        };
1604        assert!(is_tautological_point(&candidate));
1605    }
1606
1607    #[test]
1608    fn city_bbox_is_not_tautological() {
1609        let candidate = PointCandidate {
1610            key: "loc".to_string(),
1611            predicate: PointPredicate::WithinBBox {
1612                lower_left: point_lonlat(4.7, 52.3),
1613                upper_right: point_lonlat(5.0, 52.5),
1614            },
1615        };
1616        assert!(!is_tautological_point(&candidate));
1617    }
1618
1619    #[test]
1620    fn distance_bbox_is_never_tautological() {
1621        // Distance probes always narrow — no constant form folds them
1622        // into "all rows".
1623        let candidate = PointCandidate {
1624            key: "loc".to_string(),
1625            predicate: PointPredicate::WithinDistance {
1626                center: point_lonlat(0.0, 0.0),
1627                max_distance: lit_int(1_000_000_000),
1628                inclusive: true,
1629            },
1630        };
1631        assert!(!is_tautological_point(&candidate));
1632    }
1633}