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::OptionalMatch(om) => PhysicalOp::OptionalMatch(OptionalMatchExec {
358            input: om.input,
359            inner: om.inner,
360            new_vars: om.new_vars,
361        }),
362
363        LogicalOp::PathBuild(pb) => PhysicalOp::PathBuild(PathBuildExec {
364            input: pb.input,
365            output: pb.output,
366            node_vars: pb.node_vars,
367            rel_vars: pb.rel_vars,
368            shortest_path_all: pb.shortest_path_all,
369        }),
370    }
371}
372
373fn lower_node_scan(scan: NodeScan) -> PhysicalOp {
374    if scan.labels.is_empty() {
375        PhysicalOp::NodeScan(NodeScanExec {
376            input: scan.input,
377            var: scan.var,
378        })
379    } else {
380        PhysicalOp::NodeByLabelScan(NodeByLabelScanExec {
381            input: scan.input,
382            var: scan.var,
383            labels: scan.labels,
384        })
385    }
386}
387
388fn collect_vars(expr: &ResolvedExpr) -> BTreeSet<VarId> {
389    let mut vars = BTreeSet::new();
390    collect_vars_inner(expr, &mut vars);
391    vars
392}
393
394/// Build every applicable index rewrite for a `Filter(NodeScan)` site,
395/// dropping ones whose extracted predicate is trivially true (would
396/// "match everything" — see [`is_tautological_*`] helpers). Each entry
397/// is a fully-formed `LogicalOp` ready to drop into `plan.nodes[input]`.
398fn collect_index_candidates(
399    scan: &NodeScan,
400    predicate: &ResolvedExpr,
401    stats: &GraphStats,
402) -> Vec<LogicalOp> {
403    let mut out = Vec::new();
404
405    if let Some((var, key, value)) = property_equality_for_var(predicate, scan.var) {
406        out.push(LogicalOp::NodeByPropertyScan(NodeByPropertyScan {
407            input: scan.input,
408            var,
409            labels: scan.labels.clone(),
410            key,
411            value,
412        }));
413    }
414
415    if let Some(bounds) = collect_range_bounds(predicate, scan.var) {
416        if !is_tautological_range(&bounds)
417            && first_simple_label(&scan.labels)
418                .is_some_and(|label| stats.has_node_range_index(label, &bounds.key))
419        {
420            out.push(LogicalOp::NodeByPropertyRangeScan(
421                NodeByPropertyRangeScan {
422                    input: scan.input,
423                    var: scan.var,
424                    labels: scan.labels.clone(),
425                    key: bounds.key,
426                    lo: bounds.lo,
427                    lo_inclusive: bounds.lo_inclusive,
428                    hi: bounds.hi,
429                    hi_inclusive: bounds.hi_inclusive,
430                },
431            ));
432        }
433    }
434
435    if let Some(candidate) = text_predicate_for_var(predicate, scan.var) {
436        if !is_tautological_text(&candidate)
437            && first_simple_label(&scan.labels)
438                .is_some_and(|label| stats.has_node_text_index(label, &candidate.key))
439        {
440            out.push(LogicalOp::NodeByTextScan(NodeByTextScan {
441                input: scan.input,
442                var: scan.var,
443                labels: scan.labels.clone(),
444                key: candidate.key,
445                predicate: candidate.predicate,
446                query: candidate.query,
447            }));
448        }
449    }
450
451    if let Some(candidate) = point_predicate_for_var(predicate, scan.var) {
452        if !is_tautological_point(&candidate)
453            && first_simple_label(&scan.labels)
454                .is_some_and(|label| stats.has_node_point_index(label, &candidate.key))
455        {
456            out.push(LogicalOp::NodeByPointScan(NodeByPointScan {
457                input: scan.input,
458                var: scan.var,
459                labels: scan.labels.clone(),
460                key: candidate.key,
461                predicate: candidate.predicate,
462            }));
463        }
464    }
465
466    out
467}
468
469/// Build every applicable rel-targeted index rewrite for a
470/// `Filter(Expand(NodeScan, …), pred-on-rel-var)` site. Mirrors
471/// [`collect_index_candidates`] for nodes; tautological predicates are
472/// dropped through the same `is_tautological_*` helpers.
473fn collect_rel_index_candidates(
474    expand: &Expand,
475    predicate: &ResolvedExpr,
476    rel_var: VarId,
477    input: Option<PlanNodeId>,
478    stats: &GraphStats,
479) -> Vec<LogicalOp> {
480    let mut out = Vec::new();
481
482    if let Some(candidate) = text_predicate_for_var(predicate, rel_var) {
483        if !is_tautological_text(&candidate)
484            && rel_types_have_index(&expand.types, |ty| {
485                stats.has_relationship_text_index(ty, &candidate.key)
486            })
487        {
488            out.push(LogicalOp::RelByTextScan(RelByTextScan {
489                input,
490                src: expand.src,
491                rel: rel_var,
492                dst: expand.dst,
493                types: expand.types.clone(),
494                direction: expand.direction,
495                key: candidate.key,
496                predicate: candidate.predicate,
497                query: candidate.query,
498            }));
499        }
500    }
501
502    if let Some(bounds) = collect_range_bounds(predicate, rel_var) {
503        if !is_tautological_range(&bounds)
504            && rel_types_have_index(&expand.types, |ty| {
505                stats.has_relationship_range_index(ty, &bounds.key)
506            })
507        {
508            out.push(LogicalOp::RelByPropertyRangeScan(RelByPropertyRangeScan {
509                input,
510                src: expand.src,
511                rel: rel_var,
512                dst: expand.dst,
513                types: expand.types.clone(),
514                direction: expand.direction,
515                key: bounds.key,
516                lo: bounds.lo,
517                lo_inclusive: bounds.lo_inclusive,
518                hi: bounds.hi,
519                hi_inclusive: bounds.hi_inclusive,
520            }));
521        }
522    }
523
524    if let Some(candidate) = point_predicate_for_var(predicate, rel_var) {
525        if !is_tautological_point(&candidate)
526            && rel_types_have_index(&expand.types, |ty| {
527                stats.has_relationship_point_index(ty, &candidate.key)
528            })
529        {
530            out.push(LogicalOp::RelByPointScan(RelByPointScan {
531                input,
532                src: expand.src,
533                rel: rel_var,
534                dst: expand.dst,
535                types: expand.types.clone(),
536                direction: expand.direction,
537                key: candidate.key,
538                predicate: candidate.predicate,
539            }));
540        }
541    }
542
543    out
544}
545
546fn rel_types_have_index<F>(types: &[String], mut has_index: F) -> bool
547where
548    F: FnMut(&str) -> bool,
549{
550    !types.is_empty() && types.iter().all(|ty| has_index(ty))
551}
552
553/// Choose the cheapest replacement for the original `Filter(NodeScan)`
554/// input among `candidates`, breaking ties by the caller-provided
555/// collection order so behaviour is deterministic. Returns
556/// `None` when no candidate is strictly cheaper than the original — in
557/// that case the caller leaves the plan unchanged.
558fn pick_best_candidate(
559    original: &LogicalOp,
560    candidates: Vec<LogicalOp>,
561    stats: &GraphStats,
562) -> Option<LogicalOp> {
563    if candidates.is_empty() {
564        return None;
565    }
566
567    let baseline = score_logical_op(original, stats);
568
569    let mut best: Option<(LogicalOp, Option<u64>)> = None;
570    for candidate in candidates {
571        let score = score_logical_op(&candidate, stats);
572        if !improves_over(score, baseline) {
573            continue;
574        }
575        let take = match &best {
576            None => true,
577            Some((_, current_best)) => is_cheaper(score, *current_best),
578        };
579        if take {
580            best = Some((candidate, score));
581        }
582    }
583
584    best.map(|(op, _)| op)
585}
586
587/// `true` when committing a candidate with `score` is at least as good
588/// as keeping the original (`baseline`). With unknown stats the
589/// optimizer keeps its pre-cost-model behaviour: a `None`/`None`
590/// comparison still commits already-collected candidates. Catalog
591/// checks happen before this point for range/text/point operators.
592fn improves_over(score: Option<u64>, baseline: Option<u64>) -> bool {
593    match (score, baseline) {
594        (Some(s), Some(b)) => s <= b,
595        (Some(_), None) => true,
596        (None, Some(_)) => false,
597        (None, None) => true,
598    }
599}
600
601fn is_cheaper(score: Option<u64>, current_best: Option<u64>) -> bool {
602    match (score, current_best) {
603        (Some(s), Some(b)) => s < b,
604        (Some(_), None) => true,
605        (None, _) => false,
606    }
607}
608
609/// Estimated row count produced by `op`. `None` means "no information"
610/// — typically because the relevant labels or property are not in the
611/// stats snapshot. Mirrors the per-operator estimates that `EXPLAIN`
612/// surfaces in [`crate::plan_tree::PlanTreeNode::estimated_rows`], so
613/// the optimizer's pick agrees with what users see in the plan tree.
614fn score_logical_op(op: &LogicalOp, stats: &GraphStats) -> Option<u64> {
615    match op {
616        LogicalOp::NodeScan(scan) => match label_estimate(&scan.labels, stats) {
617            Some(rows) => Some(rows),
618            None if scan.labels.is_empty() => Some(stats.node_count as u64),
619            None => None,
620        },
621        LogicalOp::NodeByPropertyScan(scan) => {
622            let label = first_simple_label(&scan.labels)?;
623            stats.estimate_node_property_equality(label, &scan.key)
624        }
625        LogicalOp::NodeByPropertyRangeScan(scan) => {
626            // Conservative one-third selectivity: a one-sided range
627            // typically narrows by less than half; a two-sided range
628            // narrows further. Better than full-label scan for any
629            // useful range, never worse than `label_count`.
630            let base = label_estimate(&scan.labels, stats)?;
631            let denom = match (scan.lo.is_some(), scan.hi.is_some()) {
632                (true, true) => 4,
633                _ => 3,
634            };
635            Some(base.div_ceil(denom))
636        }
637        LogicalOp::NodeByTextScan(scan) => {
638            let base = label_estimate(&scan.labels, stats)?;
639            // Prefix/suffix probes typically narrow more than CONTAINS.
640            let denom = match scan.predicate {
641                TextPredicate::StartsWith | TextPredicate::EndsWith => 4,
642                TextPredicate::Contains => 2,
643            };
644            Some(base.div_ceil(denom))
645        }
646        LogicalOp::NodeByPointScan(scan) => {
647            let base = label_estimate(&scan.labels, stats)?;
648            // Spatial probes: bbox/distance usually returns a small
649            // fraction of the labelled set.
650            Some(base.div_ceil(5))
651        }
652        LogicalOp::Filter(_) => None,
653        LogicalOp::Expand(expand) => {
654            // Used as the baseline when evaluating rel-index rewrites.
655            // Without per-edge histograms we approximate edges-of-type
656            // by `relationship_type_count`, falling back to the global
657            // relationship total when no type is named.
658            let count = if expand.types.is_empty() {
659                stats.relationship_count as u64
660            } else {
661                let mut total: u64 = 0;
662                for ty in &expand.types {
663                    total = total.saturating_add(stats.relationship_type_count(ty)?);
664                }
665                total
666            };
667            // Undirected expansion produces both orientations.
668            Some(match expand.direction {
669                lora_ast::Direction::Undirected => count.saturating_mul(2),
670                _ => count,
671            })
672        }
673        LogicalOp::RelByPropertyRangeScan(scan) => {
674            let base = rel_type_estimate(&scan.types, stats)?;
675            let denom = match (scan.lo.is_some(), scan.hi.is_some()) {
676                (true, true) => 4,
677                _ => 3,
678            };
679            let est = base.div_ceil(denom);
680            Some(match scan.direction {
681                lora_ast::Direction::Undirected => est.saturating_mul(2),
682                _ => est,
683            })
684        }
685        LogicalOp::RelByTextScan(scan) => {
686            let base = rel_type_estimate(&scan.types, stats)?;
687            let denom = match scan.predicate {
688                TextPredicate::StartsWith | TextPredicate::EndsWith => 4,
689                TextPredicate::Contains => 2,
690            };
691            let est = base.div_ceil(denom);
692            Some(match scan.direction {
693                lora_ast::Direction::Undirected => est.saturating_mul(2),
694                _ => est,
695            })
696        }
697        LogicalOp::RelByPointScan(scan) => {
698            let base = rel_type_estimate(&scan.types, stats)?;
699            let est = base.div_ceil(5);
700            Some(match scan.direction {
701                lora_ast::Direction::Undirected => est.saturating_mul(2),
702                _ => est,
703            })
704        }
705        _ => None,
706    }
707}
708
709fn rel_type_estimate(types: &[String], stats: &GraphStats) -> Option<u64> {
710    if types.is_empty() {
711        return Some(stats.relationship_count as u64);
712    }
713    let mut total: u64 = 0;
714    for ty in types {
715        total = total.saturating_add(stats.relationship_type_count(ty)?);
716    }
717    Some(total)
718}
719
720/// Return the count of nodes covered by a `labels` group, taking the
721/// first DNF disjunction's first literal. Mirrors `labels_estimate`
722/// from `lora-database/src/database/explain.rs` — keeping the two in
723/// sync ensures `EXPLAIN` and the optimizer agree.
724fn label_estimate(labels: &[Vec<String>], stats: &GraphStats) -> Option<u64> {
725    let label = first_simple_label(labels)?;
726    stats.label_count(label)
727}
728
729fn first_simple_label(labels: &[Vec<String>]) -> Option<&str> {
730    labels.first()?.first().map(String::as_str)
731}
732
733fn is_tautological_range(bounds: &RangeBounds) -> bool {
734    let lo_open = match (&bounds.lo, bounds.lo_inclusive) {
735        (None, _) => true,
736        (Some(expr), false) => matches!(
737            expr,
738            ResolvedExpr::Literal(LiteralValue::Integer(v)) if *v == i64::MIN
739        ),
740        (Some(_), true) => false,
741    };
742    let hi_open = match (&bounds.hi, bounds.hi_inclusive) {
743        (None, _) => true,
744        (Some(expr), false) => matches!(
745            expr,
746            ResolvedExpr::Literal(LiteralValue::Integer(v)) if *v == i64::MAX
747        ),
748        (Some(_), true) => false,
749    };
750    lo_open && hi_open
751}
752
753fn is_tautological_text(candidate: &TextCandidate) -> bool {
754    matches!(
755        &candidate.query,
756        ResolvedExpr::Literal(LiteralValue::String(s)) if s.is_empty()
757    )
758}
759
760fn is_tautological_point(candidate: &PointCandidate) -> bool {
761    match &candidate.predicate {
762        PointPredicate::WithinBBox {
763            lower_left,
764            upper_right,
765        } => is_world_bbox(lower_left, upper_right),
766        PointPredicate::WithinDistance { .. } => false,
767    }
768}
769
770/// `point({longitude: -180, latitude: -90})` to
771/// `point({longitude: 180, latitude: 90})` covers every WGS-84 point.
772/// Detecting the literal form avoids a trigram lookup that would yield
773/// every indexed row only to be re-filtered to the same set.
774fn is_world_bbox(lower_left: &ResolvedExpr, upper_right: &ResolvedExpr) -> bool {
775    /// Cypher parses `-180` as `Unary{Neg, Literal(180)}`, not as a
776    /// negative integer literal — peel one such layer so the
777    /// world-bbox detection works on natural query forms.
778    fn const_number(expr: &ResolvedExpr) -> Option<f64> {
779        match expr {
780            ResolvedExpr::Literal(LiteralValue::Float(v)) => Some(*v),
781            ResolvedExpr::Literal(LiteralValue::Integer(v)) => Some(*v as f64),
782            ResolvedExpr::Unary {
783                op: lora_ast::UnaryOp::Neg,
784                expr,
785            } => const_number(expr).map(|v| -v),
786            ResolvedExpr::Unary {
787                op: lora_ast::UnaryOp::Pos,
788                expr,
789            } => const_number(expr),
790            _ => None,
791        }
792    }
793
794    fn point_lon_lat(expr: &ResolvedExpr) -> Option<(f64, f64)> {
795        let ResolvedExpr::Function { name, args, .. } = expr else {
796            return None;
797        };
798        if !name.eq_ignore_ascii_case("point") || args.len() != 1 {
799            return None;
800        }
801        let ResolvedExpr::Map(items) = &args[0] else {
802            return None;
803        };
804        let mut lon: Option<f64> = None;
805        let mut lat: Option<f64> = None;
806        for (key, value) in items {
807            let n = const_number(value)?;
808            match key.as_str() {
809                "longitude" | "x" => lon = Some(n),
810                "latitude" | "y" => lat = Some(n),
811                _ => {}
812            }
813        }
814        Some((lon?, lat?))
815    }
816
817    let Some((ll_lon, ll_lat)) = point_lon_lat(lower_left) else {
818        return false;
819    };
820    let Some((ur_lon, ur_lat)) = point_lon_lat(upper_right) else {
821        return false;
822    };
823    ll_lon <= -180.0 && ll_lat <= -90.0 && ur_lon >= 180.0 && ur_lat >= 90.0
824}
825
826struct RangeBounds {
827    key: String,
828    lo: Option<ResolvedExpr>,
829    lo_inclusive: bool,
830    hi: Option<ResolvedExpr>,
831    hi_inclusive: bool,
832}
833
834/// Walk an AND-tree and collect any `var.prop CMP literal` bounds.
835/// Returns `None` if no comparison touches `var.prop` for a single
836/// property key — we don't try to combine multi-property bounds in v1.
837fn collect_range_bounds(predicate: &ResolvedExpr, var: VarId) -> Option<RangeBounds> {
838    let mut key: Option<String> = None;
839    let mut lo: Option<ResolvedExpr> = None;
840    let mut lo_inclusive = false;
841    let mut hi: Option<ResolvedExpr> = None;
842    let mut hi_inclusive = false;
843    let mut any = false;
844
845    walk_and_for_range(
846        predicate,
847        var,
848        &mut |found_key, side, value, inclusive| match side {
849            RangeSide::Lower => {
850                if key.as_deref().map(|k| k != found_key).unwrap_or(false) {
851                    return;
852                }
853                key = Some(found_key.to_string());
854                if lo
855                    .as_ref()
856                    .map(|current| lower_bound_is_tighter(&value, inclusive, current, lo_inclusive))
857                    .unwrap_or(true)
858                {
859                    lo = Some(value);
860                    lo_inclusive = inclusive;
861                }
862                any = true;
863            }
864            RangeSide::Upper => {
865                if key.as_deref().map(|k| k != found_key).unwrap_or(false) {
866                    return;
867                }
868                key = Some(found_key.to_string());
869                if hi
870                    .as_ref()
871                    .map(|current| upper_bound_is_tighter(&value, inclusive, current, hi_inclusive))
872                    .unwrap_or(true)
873                {
874                    hi = Some(value);
875                    hi_inclusive = inclusive;
876                }
877                any = true;
878            }
879        },
880    );
881
882    if !any {
883        return None;
884    }
885    Some(RangeBounds {
886        key: key?,
887        lo,
888        lo_inclusive,
889        hi,
890        hi_inclusive,
891    })
892}
893
894#[derive(Clone, Copy)]
895enum RangeSide {
896    Lower,
897    Upper,
898}
899
900fn lower_bound_is_tighter(
901    candidate: &ResolvedExpr,
902    candidate_inclusive: bool,
903    current: &ResolvedExpr,
904    current_inclusive: bool,
905) -> bool {
906    match compare_literal_bounds(candidate, current) {
907        Some(std::cmp::Ordering::Greater) => true,
908        Some(std::cmp::Ordering::Equal) => !candidate_inclusive && current_inclusive,
909        _ => false,
910    }
911}
912
913fn upper_bound_is_tighter(
914    candidate: &ResolvedExpr,
915    candidate_inclusive: bool,
916    current: &ResolvedExpr,
917    current_inclusive: bool,
918) -> bool {
919    match compare_literal_bounds(candidate, current) {
920        Some(std::cmp::Ordering::Less) => true,
921        Some(std::cmp::Ordering::Equal) => !candidate_inclusive && current_inclusive,
922        _ => false,
923    }
924}
925
926fn compare_literal_bounds(lhs: &ResolvedExpr, rhs: &ResolvedExpr) -> Option<std::cmp::Ordering> {
927    match (literal_number(lhs), literal_number(rhs)) {
928        (Some(a), Some(b)) => return a.partial_cmp(&b),
929        (Some(_), None) | (None, Some(_)) => return None,
930        (None, None) => {}
931    }
932
933    match (lhs, rhs) {
934        (
935            ResolvedExpr::Literal(LiteralValue::String(a)),
936            ResolvedExpr::Literal(LiteralValue::String(b)),
937        ) => Some(a.cmp(b)),
938        _ => None,
939    }
940}
941
942fn literal_number(expr: &ResolvedExpr) -> Option<f64> {
943    match expr {
944        ResolvedExpr::Literal(LiteralValue::Integer(v)) => Some(*v as f64),
945        ResolvedExpr::Literal(LiteralValue::Float(v)) => Some(*v),
946        ResolvedExpr::Unary {
947            op: lora_ast::UnaryOp::Neg,
948            expr,
949        } => literal_number(expr).map(|v| -v),
950        ResolvedExpr::Unary {
951            op: lora_ast::UnaryOp::Pos,
952            expr,
953        } => literal_number(expr),
954        _ => None,
955    }
956}
957
958fn walk_and_for_range<F>(predicate: &ResolvedExpr, var: VarId, visit: &mut F)
959where
960    F: FnMut(&str, RangeSide, ResolvedExpr, bool),
961{
962    if let ResolvedExpr::Binary {
963        lhs,
964        op: BinaryOp::And,
965        rhs,
966    } = predicate
967    {
968        walk_and_for_range(lhs, var, visit);
969        walk_and_for_range(rhs, var, visit);
970        return;
971    }
972
973    let ResolvedExpr::Binary { lhs, op, rhs } = predicate else {
974        return;
975    };
976
977    let (side, inclusive) = match op {
978        BinaryOp::Gt => (RangeSide::Lower, false),
979        BinaryOp::Ge => (RangeSide::Lower, true),
980        BinaryOp::Lt => (RangeSide::Upper, false),
981        BinaryOp::Le => (RangeSide::Upper, true),
982        _ => return,
983    };
984
985    if let Some(key) = property_access_for_var(lhs, var) {
986        if !collect_vars(rhs).contains(&var) {
987            visit(&key, side, (**rhs).clone(), inclusive);
988            return;
989        }
990    }
991    if let Some(key) = property_access_for_var(rhs, var) {
992        if !collect_vars(lhs).contains(&var) {
993            // Mirror `value CMP var.prop` to `var.prop FLIPPED_CMP value`.
994            let flipped = match side {
995                RangeSide::Lower => RangeSide::Upper,
996                RangeSide::Upper => RangeSide::Lower,
997            };
998            visit(&key, flipped, (**lhs).clone(), inclusive);
999        }
1000    }
1001}
1002
1003struct TextCandidate {
1004    key: String,
1005    predicate: TextPredicate,
1006    query: ResolvedExpr,
1007}
1008
1009struct PointCandidate {
1010    key: String,
1011    predicate: PointPredicate,
1012}
1013
1014fn point_predicate_for_var(predicate: &ResolvedExpr, var: VarId) -> Option<PointCandidate> {
1015    if let ResolvedExpr::Binary {
1016        lhs,
1017        op: BinaryOp::And,
1018        rhs,
1019    } = predicate
1020    {
1021        return point_predicate_for_var(lhs, var).or_else(|| point_predicate_for_var(rhs, var));
1022    }
1023
1024    // point.withinBBox(n.prop, ll, ur)
1025    if let ResolvedExpr::Function { name, args, .. } = predicate {
1026        if name.eq_ignore_ascii_case("point.withinbbox") && args.len() == 3 {
1027            if let Some(key) = property_access_for_var(&args[0], var) {
1028                if !collect_vars(&args[1]).contains(&var) && !collect_vars(&args[2]).contains(&var)
1029                {
1030                    return Some(PointCandidate {
1031                        key,
1032                        predicate: PointPredicate::WithinBBox {
1033                            lower_left: args[1].clone(),
1034                            upper_right: args[2].clone(),
1035                        },
1036                    });
1037                }
1038            }
1039        }
1040    }
1041
1042    // point.distance(n.prop, c) OP d  (where OP is <, <=)
1043    let ResolvedExpr::Binary { lhs, op, rhs } = predicate else {
1044        return None;
1045    };
1046    let inclusive = match op {
1047        BinaryOp::Le => true,
1048        BinaryOp::Lt => false,
1049        // Symmetric form: d >= point.distance(n.prop, c)
1050        BinaryOp::Ge => true,
1051        BinaryOp::Gt => false,
1052        _ => return None,
1053    };
1054
1055    let (call_side, scalar_side) = match op {
1056        BinaryOp::Le | BinaryOp::Lt => ((**lhs).clone(), (**rhs).clone()),
1057        BinaryOp::Ge | BinaryOp::Gt => ((**rhs).clone(), (**lhs).clone()),
1058        _ => return None,
1059    };
1060
1061    let ResolvedExpr::Function { name, args, .. } = &call_side else {
1062        return None;
1063    };
1064    if !(name.eq_ignore_ascii_case("point.distance") || name.eq_ignore_ascii_case("distance")) {
1065        return None;
1066    }
1067    if args.len() != 2 {
1068        return None;
1069    }
1070    let key = property_access_for_var(&args[0], var)?;
1071    if collect_vars(&args[1]).contains(&var) || collect_vars(&scalar_side).contains(&var) {
1072        return None;
1073    }
1074    Some(PointCandidate {
1075        key,
1076        predicate: PointPredicate::WithinDistance {
1077            center: args[1].clone(),
1078            max_distance: scalar_side,
1079            inclusive,
1080        },
1081    })
1082}
1083
1084fn text_predicate_for_var(predicate: &ResolvedExpr, var: VarId) -> Option<TextCandidate> {
1085    let ResolvedExpr::Binary { lhs, op, rhs } = predicate else {
1086        return None;
1087    };
1088
1089    if matches!(op, BinaryOp::And) {
1090        return text_predicate_for_var(lhs, var).or_else(|| text_predicate_for_var(rhs, var));
1091    }
1092
1093    let kind = match op {
1094        BinaryOp::StartsWith => TextPredicate::StartsWith,
1095        BinaryOp::EndsWith => TextPredicate::EndsWith,
1096        BinaryOp::Contains => TextPredicate::Contains,
1097        _ => return None,
1098    };
1099
1100    let key = property_access_for_var(lhs, var)?;
1101    if collect_vars(rhs).contains(&var) {
1102        return None;
1103    }
1104    Some(TextCandidate {
1105        key,
1106        predicate: kind,
1107        query: (**rhs).clone(),
1108    })
1109}
1110
1111fn property_equality_for_var(
1112    predicate: &ResolvedExpr,
1113    var: VarId,
1114) -> Option<(VarId, String, ResolvedExpr)> {
1115    let ResolvedExpr::Binary { lhs, op, rhs } = predicate else {
1116        return None;
1117    };
1118
1119    if matches!(op, BinaryOp::And) {
1120        return property_equality_for_var(lhs, var).or_else(|| property_equality_for_var(rhs, var));
1121    }
1122
1123    if !matches!(op, BinaryOp::Eq) {
1124        return None;
1125    }
1126
1127    property_access_for_var(lhs, var)
1128        .filter(|_| !collect_vars(rhs).contains(&var))
1129        .map(|key| (var, key, (**rhs).clone()))
1130        .or_else(|| {
1131            property_access_for_var(rhs, var)
1132                .filter(|_| !collect_vars(lhs).contains(&var))
1133                .map(|key| (var, key, (**lhs).clone()))
1134        })
1135}
1136
1137fn static_limit_bound(limit: &Limit) -> Option<usize> {
1138    let limit_rows = match &limit.limit {
1139        Some(expr) => static_non_negative_usize(expr)?,
1140        None => return None,
1141    };
1142    let skip_rows = limit
1143        .skip
1144        .as_ref()
1145        .and_then(static_non_negative_usize)
1146        .unwrap_or(0);
1147    Some(skip_rows.saturating_add(limit_rows))
1148}
1149
1150fn limit_sort_bound(op: &LogicalOp) -> Option<(PlanNodeId, usize)> {
1151    let LogicalOp::Limit(limit) = op else {
1152        return None;
1153    };
1154
1155    static_limit_bound(limit).map(|bound| (limit.input, bound))
1156}
1157
1158fn sort_op_mut(op: &mut LogicalOp) -> Option<&mut Sort> {
1159    match op {
1160        LogicalOp::Sort(sort) => Some(sort),
1161        _ => None,
1162    }
1163}
1164
1165fn merge_top_k_bound(current: Option<usize>, bound: usize) -> Option<usize> {
1166    Some(current.map(|current| current.min(bound)).unwrap_or(bound))
1167}
1168
1169fn static_non_negative_usize(expr: &ResolvedExpr) -> Option<usize> {
1170    match expr {
1171        ResolvedExpr::Literal(LiteralValue::Integer(value)) => {
1172            Some((*value).max(0).try_into().unwrap_or(usize::MAX))
1173        }
1174        _ => None,
1175    }
1176}
1177
1178fn property_access_for_var(expr: &ResolvedExpr, var: VarId) -> Option<String> {
1179    match expr {
1180        ResolvedExpr::Property { expr, property } => match &**expr {
1181            ResolvedExpr::Variable(v) if *v == var => Some(property.clone()),
1182            _ => None,
1183        },
1184        _ => None,
1185    }
1186}
1187
1188fn collect_vars_inner(expr: &ResolvedExpr, out: &mut BTreeSet<VarId>) {
1189    match expr {
1190        ResolvedExpr::Variable(v) => {
1191            out.insert(*v);
1192        }
1193        ResolvedExpr::Property { expr, .. } => collect_vars_inner(expr, out),
1194        ResolvedExpr::Binary { lhs, rhs, .. } => {
1195            collect_vars_inner(lhs, out);
1196            collect_vars_inner(rhs, out);
1197        }
1198        ResolvedExpr::Unary { expr, .. } => collect_vars_inner(expr, out),
1199        ResolvedExpr::Function { args, .. } => {
1200            for arg in args {
1201                collect_vars_inner(arg, out);
1202            }
1203        }
1204        ResolvedExpr::List(items) => {
1205            for item in items {
1206                collect_vars_inner(item, out);
1207            }
1208        }
1209        ResolvedExpr::Map(items) => {
1210            for (_, v) in items {
1211                collect_vars_inner(v, out);
1212            }
1213        }
1214        ResolvedExpr::Case {
1215            input,
1216            alternatives,
1217            else_expr,
1218        } => {
1219            if let Some(e) = input {
1220                collect_vars_inner(e, out);
1221            }
1222            for (w, t) in alternatives {
1223                collect_vars_inner(w, out);
1224                collect_vars_inner(t, out);
1225            }
1226            if let Some(e) = else_expr {
1227                collect_vars_inner(e, out);
1228            }
1229        }
1230        ResolvedExpr::ListPredicate {
1231            variable,
1232            list,
1233            predicate,
1234            ..
1235        } => {
1236            out.insert(*variable);
1237            collect_vars_inner(list, out);
1238            collect_vars_inner(predicate, out);
1239        }
1240        ResolvedExpr::ListComprehension {
1241            variable,
1242            list,
1243            filter,
1244            map_expr,
1245            ..
1246        } => {
1247            out.insert(*variable);
1248            collect_vars_inner(list, out);
1249            if let Some(f) = filter {
1250                collect_vars_inner(f, out);
1251            }
1252            if let Some(m) = map_expr {
1253                collect_vars_inner(m, out);
1254            }
1255        }
1256        ResolvedExpr::Reduce {
1257            accumulator,
1258            init,
1259            variable,
1260            list,
1261            expr,
1262            ..
1263        } => {
1264            out.insert(*accumulator);
1265            out.insert(*variable);
1266            collect_vars_inner(init, out);
1267            collect_vars_inner(list, out);
1268            collect_vars_inner(expr, out);
1269        }
1270        ResolvedExpr::Index { expr, index } => {
1271            collect_vars_inner(expr, out);
1272            collect_vars_inner(index, out);
1273        }
1274        ResolvedExpr::Slice { expr, from, to } => {
1275            collect_vars_inner(expr, out);
1276            if let Some(f) = from {
1277                collect_vars_inner(f, out);
1278            }
1279            if let Some(t) = to {
1280                collect_vars_inner(t, out);
1281            }
1282        }
1283        ResolvedExpr::MapProjection { base, selectors } => {
1284            collect_vars_inner(base, out);
1285            for sel in selectors {
1286                if let lora_analyzer::ResolvedMapSelector::Literal(_, e) = sel {
1287                    collect_vars_inner(e, out);
1288                }
1289            }
1290        }
1291        _ => {}
1292    }
1293}
1294
1295#[cfg(test)]
1296mod tests {
1297    use super::*;
1298    use lora_store::GraphStats;
1299
1300    fn stats_with_label(label: &str, total: usize, distinct: Option<usize>) -> GraphStats {
1301        let mut s = GraphStats {
1302            node_count: total,
1303            ..Default::default()
1304        };
1305        s.nodes_by_label.insert(label.to_string(), total);
1306        if let Some(d) = distinct {
1307            s.node_distinct_values
1308                .insert((label.to_string(), "id".to_string()), d);
1309        }
1310        s
1311    }
1312
1313    fn person_labels() -> Vec<Vec<String>> {
1314        vec![vec!["Person".to_string()]]
1315    }
1316
1317    fn lit_int(v: i64) -> ResolvedExpr {
1318        ResolvedExpr::Literal(LiteralValue::Integer(v))
1319    }
1320
1321    fn lit_str(s: &str) -> ResolvedExpr {
1322        ResolvedExpr::Literal(LiteralValue::String(s.to_string()))
1323    }
1324
1325    fn lit_float(v: f64) -> ResolvedExpr {
1326        ResolvedExpr::Literal(LiteralValue::Float(v))
1327    }
1328
1329    fn point_lonlat(lon: f64, lat: f64) -> ResolvedExpr {
1330        ResolvedExpr::Function {
1331            name: "point".to_string(),
1332            distinct: false,
1333            args: vec![ResolvedExpr::Map(vec![
1334                ("longitude".to_string(), lit_float(lon)),
1335                ("latitude".to_string(), lit_float(lat)),
1336            ])],
1337        }
1338    }
1339
1340    // ---------- score_logical_op ----------
1341
1342    #[test]
1343    fn score_label_scan_returns_label_count() {
1344        let stats = stats_with_label("Person", 1_000, None);
1345        let op = LogicalOp::NodeScan(NodeScan {
1346            input: None,
1347            var: VarId(0),
1348            labels: person_labels(),
1349        });
1350        assert_eq!(score_logical_op(&op, &stats), Some(1_000));
1351    }
1352
1353    #[test]
1354    fn score_property_scan_uses_distinct() {
1355        // 1000 nodes, 100 distinct values for `id`: ~10 per value.
1356        let stats = stats_with_label("Person", 1_000, Some(100));
1357        let op = LogicalOp::NodeByPropertyScan(NodeByPropertyScan {
1358            input: None,
1359            var: VarId(0),
1360            labels: person_labels(),
1361            key: "id".to_string(),
1362            value: lit_int(7),
1363        });
1364        assert_eq!(score_logical_op(&op, &stats), Some(10));
1365    }
1366
1367    #[test]
1368    fn score_property_scan_high_distinct_beats_label_scan() {
1369        // distinct == total → uniform-distribution heuristic gives 1
1370        // estimated row, well below the 100-row label scan.
1371        let stats = stats_with_label("Person", 100, Some(100));
1372        let label_score = score_logical_op(
1373            &LogicalOp::NodeScan(NodeScan {
1374                input: None,
1375                var: VarId(0),
1376                labels: person_labels(),
1377            }),
1378            &stats,
1379        );
1380        let property_score = score_logical_op(
1381            &LogicalOp::NodeByPropertyScan(NodeByPropertyScan {
1382                input: None,
1383                var: VarId(0),
1384                labels: person_labels(),
1385                key: "id".to_string(),
1386                value: lit_int(7),
1387            }),
1388            &stats,
1389        );
1390        assert!(property_score < label_score);
1391    }
1392
1393    #[test]
1394    fn score_returns_none_without_label_stats() {
1395        // Empty stats: every estimator should fail open so the optimizer
1396        // can fall back to the legacy "commit any matching rewrite"
1397        // behaviour through `improves_over(None, None)`.
1398        let stats = GraphStats::default();
1399        let op = LogicalOp::NodeScan(NodeScan {
1400            input: None,
1401            var: VarId(0),
1402            labels: person_labels(),
1403        });
1404        assert_eq!(score_logical_op(&op, &stats), None);
1405    }
1406
1407    // ---------- improves_over ----------
1408
1409    #[test]
1410    fn improves_over_legacy_fallback_when_both_unknown() {
1411        // `(None, None)` must resolve to "commit the rewrite" — that is
1412        // the contract for environments without stats.
1413        assert!(improves_over(None, None));
1414    }
1415
1416    #[test]
1417    fn improves_over_keeps_baseline_when_candidate_unknown() {
1418        assert!(!improves_over(None, Some(10)));
1419    }
1420
1421    #[test]
1422    fn improves_over_strictly_better_or_equal_wins() {
1423        assert!(improves_over(Some(5), Some(10)));
1424        assert!(improves_over(Some(10), Some(10)));
1425        assert!(!improves_over(Some(11), Some(10)));
1426    }
1427
1428    // ---------- pick_best_candidate ----------
1429
1430    #[test]
1431    fn pick_best_candidate_picks_lowest_score() {
1432        let stats = stats_with_label("Person", 1_200, Some(100));
1433        let original = LogicalOp::NodeScan(NodeScan {
1434            input: None,
1435            var: VarId(0),
1436            labels: person_labels(),
1437        });
1438        // property scan ~12 rows (1200 / 100), range scan ~400 rows.
1439        let candidates = vec![
1440            LogicalOp::NodeByPropertyRangeScan(NodeByPropertyRangeScan {
1441                input: None,
1442                var: VarId(0),
1443                labels: person_labels(),
1444                key: "age".to_string(),
1445                lo: Some(lit_int(30)),
1446                lo_inclusive: false,
1447                hi: None,
1448                hi_inclusive: false,
1449            }),
1450            LogicalOp::NodeByPropertyScan(NodeByPropertyScan {
1451                input: None,
1452                var: VarId(0),
1453                labels: person_labels(),
1454                key: "id".to_string(),
1455                value: lit_int(7),
1456            }),
1457        ];
1458        let pick = pick_best_candidate(&original, candidates, &stats).expect("expected a pick");
1459        assert!(matches!(pick, LogicalOp::NodeByPropertyScan(_)));
1460    }
1461
1462    #[test]
1463    fn pick_best_candidate_returns_none_when_no_candidate_improves() {
1464        // Stats put baseline at 1 row (1 node, distinct=1) and the
1465        // property scan also at 1 — but no rewrite improves, and we'd
1466        // still commit the candidate because of the s<=b fallback.
1467        // To prove we *can* return None, give the candidate a higher
1468        // score than baseline.
1469        let mut stats = stats_with_label("Person", 1, None);
1470        // 1 Person, 1 distinct → property scan = 1, label scan = 1.
1471        // Add a rare label so label_estimate gives 1 but the candidate
1472        // has no distinct entry → unknown score → can't improve.
1473        stats.nodes_by_label.insert("Tiny".to_string(), 1);
1474        let original = LogicalOp::NodeScan(NodeScan {
1475            input: None,
1476            var: VarId(0),
1477            labels: vec![vec!["Tiny".to_string()]],
1478        });
1479        // Candidate references a different label → label_estimate fails
1480        // → score is None. Baseline is Some(1). improves_over(None,
1481        // Some(1)) = false → no rewrite.
1482        let candidates = vec![LogicalOp::NodeByPropertyScan(NodeByPropertyScan {
1483            input: None,
1484            var: VarId(0),
1485            labels: vec![vec!["Missing".to_string()]],
1486            key: "id".to_string(),
1487            value: lit_int(7),
1488        })];
1489        assert!(pick_best_candidate(&original, candidates, &stats).is_none());
1490    }
1491
1492    // ---------- tautology guards ----------
1493
1494    #[test]
1495    fn unbounded_low_range_is_tautological() {
1496        let bounds = RangeBounds {
1497            key: "age".to_string(),
1498            lo: Some(lit_int(i64::MIN)),
1499            lo_inclusive: false,
1500            hi: None,
1501            hi_inclusive: false,
1502        };
1503        assert!(is_tautological_range(&bounds));
1504    }
1505
1506    #[test]
1507    fn unbounded_high_range_is_tautological() {
1508        let bounds = RangeBounds {
1509            key: "age".to_string(),
1510            lo: None,
1511            lo_inclusive: false,
1512            hi: Some(lit_int(i64::MAX)),
1513            hi_inclusive: false,
1514        };
1515        assert!(is_tautological_range(&bounds));
1516    }
1517
1518    #[test]
1519    fn doubly_unbounded_range_is_tautological() {
1520        let bounds = RangeBounds {
1521            key: "age".to_string(),
1522            lo: Some(lit_int(i64::MIN)),
1523            lo_inclusive: false,
1524            hi: Some(lit_int(i64::MAX)),
1525            hi_inclusive: false,
1526        };
1527        assert!(is_tautological_range(&bounds));
1528    }
1529
1530    #[test]
1531    fn ordinary_range_is_not_tautological() {
1532        let bounds = RangeBounds {
1533            key: "age".to_string(),
1534            lo: Some(lit_int(0)),
1535            lo_inclusive: false,
1536            hi: Some(lit_int(100)),
1537            hi_inclusive: false,
1538        };
1539        assert!(!is_tautological_range(&bounds));
1540    }
1541
1542    #[test]
1543    fn empty_string_starts_with_is_tautological() {
1544        let candidate = TextCandidate {
1545            key: "name".to_string(),
1546            predicate: TextPredicate::StartsWith,
1547            query: lit_str(""),
1548        };
1549        assert!(is_tautological_text(&candidate));
1550    }
1551
1552    #[test]
1553    fn nonempty_string_starts_with_is_not_tautological() {
1554        let candidate = TextCandidate {
1555            key: "name".to_string(),
1556            predicate: TextPredicate::StartsWith,
1557            query: lit_str("A"),
1558        };
1559        assert!(!is_tautological_text(&candidate));
1560    }
1561
1562    #[test]
1563    fn world_bbox_is_tautological() {
1564        let candidate = PointCandidate {
1565            key: "loc".to_string(),
1566            predicate: PointPredicate::WithinBBox {
1567                lower_left: point_lonlat(-180.0, -90.0),
1568                upper_right: point_lonlat(180.0, 90.0),
1569            },
1570        };
1571        assert!(is_tautological_point(&candidate));
1572    }
1573
1574    #[test]
1575    fn city_bbox_is_not_tautological() {
1576        let candidate = PointCandidate {
1577            key: "loc".to_string(),
1578            predicate: PointPredicate::WithinBBox {
1579                lower_left: point_lonlat(4.7, 52.3),
1580                upper_right: point_lonlat(5.0, 52.5),
1581            },
1582        };
1583        assert!(!is_tautological_point(&candidate));
1584    }
1585
1586    #[test]
1587    fn distance_bbox_is_never_tautological() {
1588        // Distance probes always narrow — no constant form folds them
1589        // into "all rows".
1590        let candidate = PointCandidate {
1591            key: "loc".to_string(),
1592            predicate: PointPredicate::WithinDistance {
1593                center: point_lonlat(0.0, 0.0),
1594                max_distance: lit_int(1_000_000_000),
1595                inclusive: true,
1596            },
1597        };
1598        assert!(!is_tautological_point(&candidate));
1599    }
1600}