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