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 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 }
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 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 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 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 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 plan.nodes[filter_input] = best;
143 }
144 }
145
146 pub fn lower_to_physical(&mut self, logical: LogicalPlan) -> PhysicalPlan {
150 let LogicalPlan { root, nodes } = logical;
151
152 let nodes = nodes.into_iter().map(lower_logical_op).collect();
153
154 PhysicalPlan { root, nodes }
155 }
156}
157
158fn can_push_filter_below_projection(filter: &LogicalOp, input: &LogicalOp) -> bool {
159 let (LogicalOp::Filter(filter), LogicalOp::Projection(proj)) = (filter, input) else {
160 return false;
161 };
162
163 if proj.distinct || proj.include_existing {
164 return false;
165 }
166
167 let output_vars: BTreeSet<VarId> = proj.items.iter().map(|item| item.output).collect();
168 let pred_vars = collect_vars(&filter.predicate);
169 !pred_vars.iter().any(|v| output_vars.contains(v))
170}
171
172fn push_filter_below_projection_at(
173 plan: &mut LogicalPlan,
174 filter_id: PlanNodeId,
175 projection_id: PlanNodeId,
176) {
177 let filter = match plan.nodes.get(filter_id).cloned() {
178 Some(LogicalOp::Filter(f)) => f,
179 _ => return,
180 };
181 let proj = match plan.nodes.get(projection_id).cloned() {
182 Some(LogicalOp::Projection(p)) => p,
183 _ => return,
184 };
185
186 plan.nodes[projection_id] = LogicalOp::Filter(Filter {
187 input: proj.input,
188 predicate: filter.predicate,
189 });
190 plan.nodes[filter_id] = LogicalOp::Projection(Projection {
191 input: projection_id,
192 distinct: proj.distinct,
193 items: proj.items,
194 include_existing: proj.include_existing,
195 });
196}
197
198fn lower_logical_op(op: LogicalOp) -> PhysicalOp {
199 match op {
200 LogicalOp::Argument(_) => PhysicalOp::Argument(ArgumentExec),
201
202 LogicalOp::NodeScan(scan) => lower_node_scan(scan),
203
204 LogicalOp::NodeByPropertyScan(scan) => {
205 PhysicalOp::NodeByPropertyScan(NodeByPropertyScanExec {
206 input: scan.input,
207 var: scan.var,
208 labels: scan.labels,
209 key: scan.key,
210 value: scan.value,
211 })
212 }
213
214 LogicalOp::NodeByPropertyRangeScan(scan) => {
215 PhysicalOp::NodeByPropertyRangeScan(NodeByPropertyRangeScanExec {
216 input: scan.input,
217 var: scan.var,
218 labels: scan.labels,
219 key: scan.key,
220 lo: scan.lo,
221 lo_inclusive: scan.lo_inclusive,
222 hi: scan.hi,
223 hi_inclusive: scan.hi_inclusive,
224 })
225 }
226
227 LogicalOp::NodeByTextScan(scan) => PhysicalOp::NodeByTextScan(NodeByTextScanExec {
228 input: scan.input,
229 var: scan.var,
230 labels: scan.labels,
231 key: scan.key,
232 predicate: scan.predicate,
233 query: scan.query,
234 }),
235
236 LogicalOp::NodeByPointScan(scan) => PhysicalOp::NodeByPointScan(NodeByPointScanExec {
237 input: scan.input,
238 var: scan.var,
239 labels: scan.labels,
240 key: scan.key,
241 predicate: scan.predicate,
242 }),
243
244 LogicalOp::RelByPropertyRangeScan(scan) => {
245 PhysicalOp::RelByPropertyRangeScan(RelByPropertyRangeScanExec {
246 input: scan.input,
247 src: scan.src,
248 rel: scan.rel,
249 dst: scan.dst,
250 types: scan.types,
251 direction: scan.direction,
252 key: scan.key,
253 lo: scan.lo,
254 lo_inclusive: scan.lo_inclusive,
255 hi: scan.hi,
256 hi_inclusive: scan.hi_inclusive,
257 })
258 }
259
260 LogicalOp::RelByTextScan(scan) => PhysicalOp::RelByTextScan(RelByTextScanExec {
261 input: scan.input,
262 src: scan.src,
263 rel: scan.rel,
264 dst: scan.dst,
265 types: scan.types,
266 direction: scan.direction,
267 key: scan.key,
268 predicate: scan.predicate,
269 query: scan.query,
270 }),
271
272 LogicalOp::RelByPointScan(scan) => PhysicalOp::RelByPointScan(RelByPointScanExec {
273 input: scan.input,
274 src: scan.src,
275 rel: scan.rel,
276 dst: scan.dst,
277 types: scan.types,
278 direction: scan.direction,
279 key: scan.key,
280 predicate: scan.predicate,
281 }),
282
283 LogicalOp::Expand(expand) => PhysicalOp::Expand(ExpandExec {
284 input: expand.input,
285 src: expand.src,
286 rel: expand.rel,
287 dst: expand.dst,
288 types: expand.types,
289 direction: expand.direction,
290 rel_properties: expand.rel_properties,
291 range: expand.range,
292 }),
293
294 LogicalOp::Filter(filter) => PhysicalOp::Filter(FilterExec {
295 input: filter.input,
296 predicate: filter.predicate,
297 }),
298
299 LogicalOp::Projection(proj) => PhysicalOp::Projection(ProjectionExec {
300 input: proj.input,
301 distinct: proj.distinct,
302 items: proj.items,
303 include_existing: proj.include_existing,
304 }),
305
306 LogicalOp::Unwind(unwind) => PhysicalOp::Unwind(UnwindExec {
307 input: unwind.input,
308 expr: unwind.expr,
309 alias: unwind.alias,
310 }),
311
312 LogicalOp::Aggregation(agg) => PhysicalOp::HashAggregation(HashAggregationExec {
313 input: agg.input,
314 group_by: agg.group_by,
315 aggregates: agg.aggregates,
316 }),
317
318 LogicalOp::Sort(sort) => PhysicalOp::Sort(SortExec {
319 input: sort.input,
320 items: sort.items,
321 top_k: sort.top_k,
322 }),
323
324 LogicalOp::Limit(limit) => PhysicalOp::Limit(LimitExec {
325 input: limit.input,
326 skip: limit.skip,
327 limit: limit.limit,
328 }),
329
330 LogicalOp::Create(create) => PhysicalOp::Create(CreateExec {
331 input: create.input,
332 pattern: create.pattern,
333 }),
334
335 LogicalOp::Merge(merge) => PhysicalOp::Merge(MergeExec {
336 input: merge.input,
337 pattern_part: merge.pattern_part,
338 actions: merge.actions,
339 }),
340
341 LogicalOp::Delete(delete) => PhysicalOp::Delete(DeleteExec {
342 input: delete.input,
343 detach: delete.detach,
344 expressions: delete.expressions,
345 }),
346
347 LogicalOp::Set(set) => PhysicalOp::Set(SetExec {
348 input: set.input,
349 items: set.items,
350 }),
351
352 LogicalOp::Remove(remove) => PhysicalOp::Remove(RemoveExec {
353 input: remove.input,
354 items: remove.items,
355 }),
356
357 LogicalOp::Foreach(foreach) => PhysicalOp::Foreach(crate::ForeachExec {
358 input: foreach.input,
359 variable: foreach.variable,
360 list: foreach.list,
361 body: foreach.body,
362 }),
363
364 LogicalOp::OptionalMatch(om) => PhysicalOp::OptionalMatch(OptionalMatchExec {
365 input: om.input,
366 inner: om.inner,
367 new_vars: om.new_vars,
368 }),
369
370 LogicalOp::PathBuild(pb) => PhysicalOp::PathBuild(PathBuildExec {
371 input: pb.input,
372 output: pb.output,
373 node_vars: pb.node_vars,
374 rel_vars: pb.rel_vars,
375 shortest_path_all: pb.shortest_path_all,
376 }),
377
378 LogicalOp::CallSubquery(cs) => PhysicalOp::CallSubquery(CallSubqueryExec {
379 input: cs.input,
380 inner: cs.inner,
381 new_vars: cs.new_vars,
382 }),
383 }
384}
385
386fn lower_node_scan(scan: NodeScan) -> PhysicalOp {
387 if scan.labels.is_empty() {
388 PhysicalOp::NodeScan(NodeScanExec {
389 input: scan.input,
390 var: scan.var,
391 })
392 } else {
393 PhysicalOp::NodeByLabelScan(NodeByLabelScanExec {
394 input: scan.input,
395 var: scan.var,
396 labels: scan.labels,
397 })
398 }
399}
400
401fn collect_vars(expr: &ResolvedExpr) -> BTreeSet<VarId> {
402 let mut vars = BTreeSet::new();
403 collect_vars_inner(expr, &mut vars);
404 vars
405}
406
407fn collect_index_candidates(
412 scan: &NodeScan,
413 predicate: &ResolvedExpr,
414 stats: &GraphStats,
415) -> Vec<LogicalOp> {
416 let mut out = Vec::new();
417
418 if let Some((var, key, value)) = property_equality_for_var(predicate, scan.var) {
419 out.push(LogicalOp::NodeByPropertyScan(NodeByPropertyScan {
420 input: scan.input,
421 var,
422 labels: scan.labels.clone(),
423 key,
424 value,
425 }));
426 }
427
428 if let Some(bounds) = collect_range_bounds(predicate, scan.var) {
429 if !is_tautological_range(&bounds)
430 && first_simple_label(&scan.labels)
431 .is_some_and(|label| stats.has_node_range_index(label, &bounds.key))
432 {
433 out.push(LogicalOp::NodeByPropertyRangeScan(
434 NodeByPropertyRangeScan {
435 input: scan.input,
436 var: scan.var,
437 labels: scan.labels.clone(),
438 key: bounds.key,
439 lo: bounds.lo,
440 lo_inclusive: bounds.lo_inclusive,
441 hi: bounds.hi,
442 hi_inclusive: bounds.hi_inclusive,
443 },
444 ));
445 }
446 }
447
448 if let Some(candidate) = text_predicate_for_var(predicate, scan.var) {
449 if !is_tautological_text(&candidate)
450 && first_simple_label(&scan.labels)
451 .is_some_and(|label| stats.has_node_text_index(label, &candidate.key))
452 {
453 out.push(LogicalOp::NodeByTextScan(NodeByTextScan {
454 input: scan.input,
455 var: scan.var,
456 labels: scan.labels.clone(),
457 key: candidate.key,
458 predicate: candidate.predicate,
459 query: candidate.query,
460 }));
461 }
462 }
463
464 if let Some(candidate) = point_predicate_for_var(predicate, scan.var) {
465 if !is_tautological_point(&candidate)
466 && first_simple_label(&scan.labels)
467 .is_some_and(|label| stats.has_node_point_index(label, &candidate.key))
468 {
469 out.push(LogicalOp::NodeByPointScan(NodeByPointScan {
470 input: scan.input,
471 var: scan.var,
472 labels: scan.labels.clone(),
473 key: candidate.key,
474 predicate: candidate.predicate,
475 }));
476 }
477 }
478
479 out
480}
481
482fn collect_rel_index_candidates(
487 expand: &Expand,
488 predicate: &ResolvedExpr,
489 rel_var: VarId,
490 input: Option<PlanNodeId>,
491 stats: &GraphStats,
492) -> Vec<LogicalOp> {
493 let mut out = Vec::new();
494
495 if let Some(candidate) = text_predicate_for_var(predicate, rel_var) {
496 if !is_tautological_text(&candidate)
497 && rel_types_have_index(&expand.types, |ty| {
498 stats.has_relationship_text_index(ty, &candidate.key)
499 })
500 {
501 out.push(LogicalOp::RelByTextScan(RelByTextScan {
502 input,
503 src: expand.src,
504 rel: rel_var,
505 dst: expand.dst,
506 types: expand.types.clone(),
507 direction: expand.direction,
508 key: candidate.key,
509 predicate: candidate.predicate,
510 query: candidate.query,
511 }));
512 }
513 }
514
515 if let Some(bounds) = collect_range_bounds(predicate, rel_var) {
516 if !is_tautological_range(&bounds)
517 && rel_types_have_index(&expand.types, |ty| {
518 stats.has_relationship_range_index(ty, &bounds.key)
519 })
520 {
521 out.push(LogicalOp::RelByPropertyRangeScan(RelByPropertyRangeScan {
522 input,
523 src: expand.src,
524 rel: rel_var,
525 dst: expand.dst,
526 types: expand.types.clone(),
527 direction: expand.direction,
528 key: bounds.key,
529 lo: bounds.lo,
530 lo_inclusive: bounds.lo_inclusive,
531 hi: bounds.hi,
532 hi_inclusive: bounds.hi_inclusive,
533 }));
534 }
535 }
536
537 if let Some(candidate) = point_predicate_for_var(predicate, rel_var) {
538 if !is_tautological_point(&candidate)
539 && rel_types_have_index(&expand.types, |ty| {
540 stats.has_relationship_point_index(ty, &candidate.key)
541 })
542 {
543 out.push(LogicalOp::RelByPointScan(RelByPointScan {
544 input,
545 src: expand.src,
546 rel: rel_var,
547 dst: expand.dst,
548 types: expand.types.clone(),
549 direction: expand.direction,
550 key: candidate.key,
551 predicate: candidate.predicate,
552 }));
553 }
554 }
555
556 out
557}
558
559fn rel_types_have_index<F>(types: &[String], mut has_index: F) -> bool
560where
561 F: FnMut(&str) -> bool,
562{
563 !types.is_empty() && types.iter().all(|ty| has_index(ty))
564}
565
566fn pick_best_candidate(
572 original: &LogicalOp,
573 candidates: Vec<LogicalOp>,
574 stats: &GraphStats,
575) -> Option<LogicalOp> {
576 if candidates.is_empty() {
577 return None;
578 }
579
580 let baseline = score_logical_op(original, stats);
581
582 let mut best: Option<(LogicalOp, Option<u64>)> = None;
583 for candidate in candidates {
584 let score = score_logical_op(&candidate, stats);
585 if !improves_over(score, baseline) {
586 continue;
587 }
588 let take = match &best {
589 None => true,
590 Some((_, current_best)) => is_cheaper(score, *current_best),
591 };
592 if take {
593 best = Some((candidate, score));
594 }
595 }
596
597 best.map(|(op, _)| op)
598}
599
600fn improves_over(score: Option<u64>, baseline: Option<u64>) -> bool {
606 match (score, baseline) {
607 (Some(s), Some(b)) => s <= b,
608 (Some(_), None) => true,
609 (None, Some(_)) => false,
610 (None, None) => true,
611 }
612}
613
614fn is_cheaper(score: Option<u64>, current_best: Option<u64>) -> bool {
615 match (score, current_best) {
616 (Some(s), Some(b)) => s < b,
617 (Some(_), None) => true,
618 (None, _) => false,
619 }
620}
621
622fn score_logical_op(op: &LogicalOp, stats: &GraphStats) -> Option<u64> {
628 match op {
629 LogicalOp::NodeScan(scan) => match label_estimate(&scan.labels, stats) {
630 Some(rows) => Some(rows),
631 None if scan.labels.is_empty() => Some(stats.node_count as u64),
632 None => None,
633 },
634 LogicalOp::NodeByPropertyScan(scan) => {
635 let label = first_simple_label(&scan.labels)?;
636 stats.estimate_node_property_equality(label, &scan.key)
637 }
638 LogicalOp::NodeByPropertyRangeScan(scan) => {
639 let base = label_estimate(&scan.labels, stats)?;
644 let denom = match (scan.lo.is_some(), scan.hi.is_some()) {
645 (true, true) => 4,
646 _ => 3,
647 };
648 Some(base.div_ceil(denom))
649 }
650 LogicalOp::NodeByTextScan(scan) => {
651 let base = label_estimate(&scan.labels, stats)?;
652 let denom = match scan.predicate {
654 TextPredicate::StartsWith | TextPredicate::EndsWith => 4,
655 TextPredicate::Contains => 2,
656 };
657 Some(base.div_ceil(denom))
658 }
659 LogicalOp::NodeByPointScan(scan) => {
660 let base = label_estimate(&scan.labels, stats)?;
661 Some(base.div_ceil(5))
664 }
665 LogicalOp::Filter(_) => None,
666 LogicalOp::Expand(expand) => {
667 let count = if expand.types.is_empty() {
672 stats.relationship_count as u64
673 } else {
674 let mut total: u64 = 0;
675 for ty in &expand.types {
676 total = total.saturating_add(stats.relationship_type_count(ty)?);
677 }
678 total
679 };
680 Some(match expand.direction {
682 lora_ast::Direction::Undirected => count.saturating_mul(2),
683 _ => count,
684 })
685 }
686 LogicalOp::RelByPropertyRangeScan(scan) => {
687 let base = rel_type_estimate(&scan.types, stats)?;
688 let denom = match (scan.lo.is_some(), scan.hi.is_some()) {
689 (true, true) => 4,
690 _ => 3,
691 };
692 let est = base.div_ceil(denom);
693 Some(match scan.direction {
694 lora_ast::Direction::Undirected => est.saturating_mul(2),
695 _ => est,
696 })
697 }
698 LogicalOp::RelByTextScan(scan) => {
699 let base = rel_type_estimate(&scan.types, stats)?;
700 let denom = match scan.predicate {
701 TextPredicate::StartsWith | TextPredicate::EndsWith => 4,
702 TextPredicate::Contains => 2,
703 };
704 let est = base.div_ceil(denom);
705 Some(match scan.direction {
706 lora_ast::Direction::Undirected => est.saturating_mul(2),
707 _ => est,
708 })
709 }
710 LogicalOp::RelByPointScan(scan) => {
711 let base = rel_type_estimate(&scan.types, stats)?;
712 let est = base.div_ceil(5);
713 Some(match scan.direction {
714 lora_ast::Direction::Undirected => est.saturating_mul(2),
715 _ => est,
716 })
717 }
718 _ => None,
719 }
720}
721
722fn rel_type_estimate(types: &[String], stats: &GraphStats) -> Option<u64> {
723 if types.is_empty() {
724 return Some(stats.relationship_count as u64);
725 }
726 let mut total: u64 = 0;
727 for ty in types {
728 total = total.saturating_add(stats.relationship_type_count(ty)?);
729 }
730 Some(total)
731}
732
733fn label_estimate(labels: &[Vec<String>], stats: &GraphStats) -> Option<u64> {
738 let label = first_simple_label(labels)?;
739 stats.label_count(label)
740}
741
742fn first_simple_label(labels: &[Vec<String>]) -> Option<&str> {
743 labels.first()?.first().map(String::as_str)
744}
745
746fn is_tautological_range(bounds: &RangeBounds) -> bool {
747 let lo_open = match (&bounds.lo, bounds.lo_inclusive) {
748 (None, _) => true,
749 (Some(expr), false) => matches!(
750 expr,
751 ResolvedExpr::Literal(LiteralValue::Integer(v)) if *v == i64::MIN
752 ),
753 (Some(_), true) => false,
754 };
755 let hi_open = match (&bounds.hi, bounds.hi_inclusive) {
756 (None, _) => true,
757 (Some(expr), false) => matches!(
758 expr,
759 ResolvedExpr::Literal(LiteralValue::Integer(v)) if *v == i64::MAX
760 ),
761 (Some(_), true) => false,
762 };
763 lo_open && hi_open
764}
765
766fn is_tautological_text(candidate: &TextCandidate) -> bool {
767 matches!(
768 &candidate.query,
769 ResolvedExpr::Literal(LiteralValue::String(s)) if s.is_empty()
770 )
771}
772
773fn is_tautological_point(candidate: &PointCandidate) -> bool {
774 match &candidate.predicate {
775 PointPredicate::WithinBBox {
776 lower_left,
777 upper_right,
778 } => is_world_bbox(lower_left, upper_right),
779 PointPredicate::WithinDistance { .. } => false,
780 }
781}
782
783fn is_world_bbox(lower_left: &ResolvedExpr, upper_right: &ResolvedExpr) -> bool {
788 fn const_number(expr: &ResolvedExpr) -> Option<f64> {
792 match expr {
793 ResolvedExpr::Literal(LiteralValue::Float(v)) => Some(*v),
794 ResolvedExpr::Literal(LiteralValue::Integer(v)) => Some(*v as f64),
795 ResolvedExpr::Unary {
796 op: lora_ast::UnaryOp::Neg,
797 expr,
798 } => const_number(expr).map(|v| -v),
799 ResolvedExpr::Unary {
800 op: lora_ast::UnaryOp::Pos,
801 expr,
802 } => const_number(expr),
803 _ => None,
804 }
805 }
806
807 fn point_lon_lat(expr: &ResolvedExpr) -> Option<(f64, f64)> {
808 let items = point_literal_map(expr)?;
809 let mut lon: Option<f64> = None;
810 let mut lat: Option<f64> = None;
811 for (key, value) in items {
812 let n = const_number(value)?;
813 match key.as_str() {
814 "longitude" | "x" => lon = Some(n),
815 "latitude" | "y" => lat = Some(n),
816 _ => {}
817 }
818 }
819 Some((lon?, lat?))
820 }
821
822 let Some((ll_lon, ll_lat)) = point_lon_lat(lower_left) else {
823 return false;
824 };
825 let Some((ur_lon, ur_lat)) = point_lon_lat(upper_right) else {
826 return false;
827 };
828 ll_lon <= -180.0 && ll_lat <= -90.0 && ur_lon >= 180.0 && ur_lat >= 90.0
829}
830
831fn point_literal_map(expr: &ResolvedExpr) -> Option<&Vec<(String, ResolvedExpr)>> {
832 let ResolvedExpr::Function { function, args, .. } = expr else {
833 return None;
834 };
835 if function.eq_ignore_ascii_case("geo.point") && args.len() == 1 {
836 let ResolvedExpr::Map(items) = &args[0] else {
837 return None;
838 };
839 return Some(items);
840 }
841 if function.eq_ignore_ascii_case("cast.to") && args.len() == 2 {
842 let ResolvedExpr::Map(items) = &args[0] else {
843 return None;
844 };
845 let ResolvedExpr::Literal(LiteralValue::TypeName(target)) = &args[1] else {
846 return None;
847 };
848 if target.eq_ignore_ascii_case("POINT") {
849 return Some(items);
850 }
851 }
852 None
853}
854
855struct RangeBounds {
856 key: String,
857 lo: Option<ResolvedExpr>,
858 lo_inclusive: bool,
859 hi: Option<ResolvedExpr>,
860 hi_inclusive: bool,
861}
862
863fn collect_range_bounds(predicate: &ResolvedExpr, var: VarId) -> Option<RangeBounds> {
867 let mut key: Option<String> = None;
868 let mut lo: Option<ResolvedExpr> = None;
869 let mut lo_inclusive = false;
870 let mut hi: Option<ResolvedExpr> = None;
871 let mut hi_inclusive = false;
872 let mut any = false;
873
874 walk_and_for_range(
875 predicate,
876 var,
877 &mut |found_key, side, value, inclusive| match side {
878 RangeSide::Lower => {
879 if key.as_deref().map(|k| k != found_key).unwrap_or(false) {
880 return;
881 }
882 key = Some(found_key.to_string());
883 if lo
884 .as_ref()
885 .map(|current| lower_bound_is_tighter(&value, inclusive, current, lo_inclusive))
886 .unwrap_or(true)
887 {
888 lo = Some(value);
889 lo_inclusive = inclusive;
890 }
891 any = true;
892 }
893 RangeSide::Upper => {
894 if key.as_deref().map(|k| k != found_key).unwrap_or(false) {
895 return;
896 }
897 key = Some(found_key.to_string());
898 if hi
899 .as_ref()
900 .map(|current| upper_bound_is_tighter(&value, inclusive, current, hi_inclusive))
901 .unwrap_or(true)
902 {
903 hi = Some(value);
904 hi_inclusive = inclusive;
905 }
906 any = true;
907 }
908 },
909 );
910
911 if !any {
912 return None;
913 }
914 Some(RangeBounds {
915 key: key?,
916 lo,
917 lo_inclusive,
918 hi,
919 hi_inclusive,
920 })
921}
922
923#[derive(Clone, Copy)]
924enum RangeSide {
925 Lower,
926 Upper,
927}
928
929fn lower_bound_is_tighter(
930 candidate: &ResolvedExpr,
931 candidate_inclusive: bool,
932 current: &ResolvedExpr,
933 current_inclusive: bool,
934) -> bool {
935 match compare_literal_bounds(candidate, current) {
936 Some(std::cmp::Ordering::Greater) => true,
937 Some(std::cmp::Ordering::Equal) => !candidate_inclusive && current_inclusive,
938 _ => false,
939 }
940}
941
942fn upper_bound_is_tighter(
943 candidate: &ResolvedExpr,
944 candidate_inclusive: bool,
945 current: &ResolvedExpr,
946 current_inclusive: bool,
947) -> bool {
948 match compare_literal_bounds(candidate, current) {
949 Some(std::cmp::Ordering::Less) => true,
950 Some(std::cmp::Ordering::Equal) => !candidate_inclusive && current_inclusive,
951 _ => false,
952 }
953}
954
955fn compare_literal_bounds(lhs: &ResolvedExpr, rhs: &ResolvedExpr) -> Option<std::cmp::Ordering> {
956 match (literal_number(lhs), literal_number(rhs)) {
957 (Some(a), Some(b)) => return a.partial_cmp(&b),
958 (Some(_), None) | (None, Some(_)) => return None,
959 (None, None) => {}
960 }
961
962 match (lhs, rhs) {
963 (
964 ResolvedExpr::Literal(LiteralValue::String(a)),
965 ResolvedExpr::Literal(LiteralValue::String(b)),
966 ) => Some(a.cmp(b)),
967 _ => None,
968 }
969}
970
971fn literal_number(expr: &ResolvedExpr) -> Option<f64> {
972 match expr {
973 ResolvedExpr::Literal(LiteralValue::Integer(v)) => Some(*v as f64),
974 ResolvedExpr::Literal(LiteralValue::Float(v)) => Some(*v),
975 ResolvedExpr::Unary {
976 op: lora_ast::UnaryOp::Neg,
977 expr,
978 } => literal_number(expr).map(|v| -v),
979 ResolvedExpr::Unary {
980 op: lora_ast::UnaryOp::Pos,
981 expr,
982 } => literal_number(expr),
983 _ => None,
984 }
985}
986
987fn walk_and_for_range<F>(predicate: &ResolvedExpr, var: VarId, visit: &mut F)
988where
989 F: FnMut(&str, RangeSide, ResolvedExpr, bool),
990{
991 if let ResolvedExpr::Binary {
992 lhs,
993 op: BinaryOp::And,
994 rhs,
995 } = predicate
996 {
997 walk_and_for_range(lhs, var, visit);
998 walk_and_for_range(rhs, var, visit);
999 return;
1000 }
1001
1002 let ResolvedExpr::Binary { lhs, op, rhs } = predicate else {
1003 return;
1004 };
1005
1006 let (side, inclusive) = match op {
1007 BinaryOp::Gt => (RangeSide::Lower, false),
1008 BinaryOp::Ge => (RangeSide::Lower, true),
1009 BinaryOp::Lt => (RangeSide::Upper, false),
1010 BinaryOp::Le => (RangeSide::Upper, true),
1011 _ => return,
1012 };
1013
1014 if let Some(key) = property_access_for_var(lhs, var) {
1015 if !collect_vars(rhs).contains(&var) {
1016 visit(&key, side, (**rhs).clone(), inclusive);
1017 return;
1018 }
1019 }
1020 if let Some(key) = property_access_for_var(rhs, var) {
1021 if !collect_vars(lhs).contains(&var) {
1022 let flipped = match side {
1024 RangeSide::Lower => RangeSide::Upper,
1025 RangeSide::Upper => RangeSide::Lower,
1026 };
1027 visit(&key, flipped, (**lhs).clone(), inclusive);
1028 }
1029 }
1030}
1031
1032struct TextCandidate {
1033 key: String,
1034 predicate: TextPredicate,
1035 query: ResolvedExpr,
1036}
1037
1038struct PointCandidate {
1039 key: String,
1040 predicate: PointPredicate,
1041}
1042
1043fn point_predicate_for_var(predicate: &ResolvedExpr, var: VarId) -> Option<PointCandidate> {
1044 if let ResolvedExpr::Binary {
1045 lhs,
1046 op: BinaryOp::And,
1047 rhs,
1048 } = predicate
1049 {
1050 return point_predicate_for_var(lhs, var).or_else(|| point_predicate_for_var(rhs, var));
1051 }
1052
1053 if let ResolvedExpr::Function { function, args, .. } = predicate {
1055 if function.eq_ignore_ascii_case("geo.within_bbox") && args.len() == 3 {
1056 if let Some(key) = property_access_for_var(&args[0], var) {
1057 if !collect_vars(&args[1]).contains(&var) && !collect_vars(&args[2]).contains(&var)
1058 {
1059 return Some(PointCandidate {
1060 key,
1061 predicate: PointPredicate::WithinBBox {
1062 lower_left: args[1].clone(),
1063 upper_right: args[2].clone(),
1064 },
1065 });
1066 }
1067 }
1068 }
1069 }
1070
1071 let ResolvedExpr::Binary { lhs, op, rhs } = predicate else {
1073 return None;
1074 };
1075 let inclusive = match op {
1076 BinaryOp::Le => true,
1077 BinaryOp::Lt => false,
1078 BinaryOp::Ge => true,
1080 BinaryOp::Gt => false,
1081 _ => return None,
1082 };
1083
1084 let (call_side, scalar_side) = match op {
1085 BinaryOp::Le | BinaryOp::Lt => ((**lhs).clone(), (**rhs).clone()),
1086 BinaryOp::Ge | BinaryOp::Gt => ((**rhs).clone(), (**lhs).clone()),
1087 _ => return None,
1088 };
1089
1090 let ResolvedExpr::Function { function, args, .. } = &call_side else {
1091 return None;
1092 };
1093 if !function.eq_ignore_ascii_case("geo.distance") {
1094 return None;
1095 }
1096 if args.len() != 2 {
1097 return None;
1098 }
1099 let key = property_access_for_var(&args[0], var)?;
1100 if collect_vars(&args[1]).contains(&var) || collect_vars(&scalar_side).contains(&var) {
1101 return None;
1102 }
1103 Some(PointCandidate {
1104 key,
1105 predicate: PointPredicate::WithinDistance {
1106 center: args[1].clone(),
1107 max_distance: scalar_side,
1108 inclusive,
1109 },
1110 })
1111}
1112
1113fn text_predicate_for_var(predicate: &ResolvedExpr, var: VarId) -> Option<TextCandidate> {
1114 let ResolvedExpr::Binary { lhs, op, rhs } = predicate else {
1115 return None;
1116 };
1117
1118 if matches!(op, BinaryOp::And) {
1119 return text_predicate_for_var(lhs, var).or_else(|| text_predicate_for_var(rhs, var));
1120 }
1121
1122 let kind = match op {
1123 BinaryOp::StartsWith => TextPredicate::StartsWith,
1124 BinaryOp::EndsWith => TextPredicate::EndsWith,
1125 BinaryOp::Contains => TextPredicate::Contains,
1126 _ => return None,
1127 };
1128
1129 let key = property_access_for_var(lhs, var)?;
1130 if collect_vars(rhs).contains(&var) {
1131 return None;
1132 }
1133 Some(TextCandidate {
1134 key,
1135 predicate: kind,
1136 query: (**rhs).clone(),
1137 })
1138}
1139
1140fn property_equality_for_var(
1141 predicate: &ResolvedExpr,
1142 var: VarId,
1143) -> Option<(VarId, String, ResolvedExpr)> {
1144 let ResolvedExpr::Binary { lhs, op, rhs } = predicate else {
1145 return None;
1146 };
1147
1148 if matches!(op, BinaryOp::And) {
1149 return property_equality_for_var(lhs, var).or_else(|| property_equality_for_var(rhs, var));
1150 }
1151
1152 if !matches!(op, BinaryOp::Eq) {
1153 return None;
1154 }
1155
1156 property_access_for_var(lhs, var)
1157 .filter(|_| !collect_vars(rhs).contains(&var))
1158 .map(|key| (var, key, (**rhs).clone()))
1159 .or_else(|| {
1160 property_access_for_var(rhs, var)
1161 .filter(|_| !collect_vars(lhs).contains(&var))
1162 .map(|key| (var, key, (**lhs).clone()))
1163 })
1164}
1165
1166fn static_limit_bound(limit: &Limit) -> Option<usize> {
1167 let limit_rows = match &limit.limit {
1168 Some(expr) => static_non_negative_usize(expr)?,
1169 None => return None,
1170 };
1171 let skip_rows = limit
1172 .skip
1173 .as_ref()
1174 .and_then(static_non_negative_usize)
1175 .unwrap_or(0);
1176 Some(skip_rows.saturating_add(limit_rows))
1177}
1178
1179fn limit_sort_bound(op: &LogicalOp) -> Option<(PlanNodeId, usize)> {
1180 let LogicalOp::Limit(limit) = op else {
1181 return None;
1182 };
1183
1184 static_limit_bound(limit).map(|bound| (limit.input, bound))
1185}
1186
1187fn sort_op_mut(op: &mut LogicalOp) -> Option<&mut Sort> {
1188 match op {
1189 LogicalOp::Sort(sort) => Some(sort),
1190 _ => None,
1191 }
1192}
1193
1194fn merge_top_k_bound(current: Option<usize>, bound: usize) -> Option<usize> {
1195 Some(current.map(|current| current.min(bound)).unwrap_or(bound))
1196}
1197
1198fn static_non_negative_usize(expr: &ResolvedExpr) -> Option<usize> {
1199 match expr {
1200 ResolvedExpr::Literal(LiteralValue::Integer(value)) => {
1201 Some((*value).max(0).try_into().unwrap_or(usize::MAX))
1202 }
1203 _ => None,
1204 }
1205}
1206
1207fn property_access_for_var(expr: &ResolvedExpr, var: VarId) -> Option<String> {
1208 match expr {
1209 ResolvedExpr::Property { expr, property } => match &**expr {
1210 ResolvedExpr::Variable(v) if *v == var => Some(property.clone()),
1211 _ => None,
1212 },
1213 _ => None,
1214 }
1215}
1216
1217fn collect_vars_inner(expr: &ResolvedExpr, out: &mut BTreeSet<VarId>) {
1218 match expr {
1219 ResolvedExpr::Variable(v) => {
1220 out.insert(*v);
1221 }
1222 ResolvedExpr::Property { expr, .. } => collect_vars_inner(expr, out),
1223 ResolvedExpr::Binary { lhs, rhs, .. } => {
1224 collect_vars_inner(lhs, out);
1225 collect_vars_inner(rhs, out);
1226 }
1227 ResolvedExpr::Unary { expr, .. } => collect_vars_inner(expr, out),
1228 ResolvedExpr::Function { args, .. } => {
1229 for arg in args {
1230 collect_vars_inner(arg, out);
1231 }
1232 }
1233 ResolvedExpr::List(items) => {
1234 for item in items {
1235 collect_vars_inner(item, out);
1236 }
1237 }
1238 ResolvedExpr::Map(items) => {
1239 for (_, v) in items {
1240 collect_vars_inner(v, out);
1241 }
1242 }
1243 ResolvedExpr::Case {
1244 input,
1245 alternatives,
1246 else_expr,
1247 } => {
1248 if let Some(e) = input {
1249 collect_vars_inner(e, out);
1250 }
1251 for (w, t) in alternatives {
1252 collect_vars_inner(w, out);
1253 collect_vars_inner(t, out);
1254 }
1255 if let Some(e) = else_expr {
1256 collect_vars_inner(e, out);
1257 }
1258 }
1259 ResolvedExpr::ListPredicate {
1260 variable,
1261 list,
1262 predicate,
1263 ..
1264 } => {
1265 out.insert(*variable);
1266 collect_vars_inner(list, out);
1267 collect_vars_inner(predicate, out);
1268 }
1269 ResolvedExpr::ListComprehension {
1270 variable,
1271 list,
1272 filter,
1273 map_expr,
1274 ..
1275 } => {
1276 out.insert(*variable);
1277 collect_vars_inner(list, out);
1278 if let Some(f) = filter {
1279 collect_vars_inner(f, out);
1280 }
1281 if let Some(m) = map_expr {
1282 collect_vars_inner(m, out);
1283 }
1284 }
1285 ResolvedExpr::Reduce {
1286 accumulator,
1287 init,
1288 variable,
1289 list,
1290 expr,
1291 ..
1292 } => {
1293 out.insert(*accumulator);
1294 out.insert(*variable);
1295 collect_vars_inner(init, out);
1296 collect_vars_inner(list, out);
1297 collect_vars_inner(expr, out);
1298 }
1299 ResolvedExpr::Index { expr, index } => {
1300 collect_vars_inner(expr, out);
1301 collect_vars_inner(index, out);
1302 }
1303 ResolvedExpr::Slice { expr, from, to } => {
1304 collect_vars_inner(expr, out);
1305 if let Some(f) = from {
1306 collect_vars_inner(f, out);
1307 }
1308 if let Some(t) = to {
1309 collect_vars_inner(t, out);
1310 }
1311 }
1312 ResolvedExpr::MapProjection { base, selectors } => {
1313 collect_vars_inner(base, out);
1314 for sel in selectors {
1315 if let lora_analyzer::ResolvedMapSelector::Literal(_, e) = sel {
1316 collect_vars_inner(e, out);
1317 }
1318 }
1319 }
1320 _ => {}
1321 }
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326 use super::*;
1327 use lora_store::GraphStats;
1328
1329 fn stats_with_label(label: &str, total: usize, distinct: Option<usize>) -> GraphStats {
1330 let mut s = GraphStats {
1331 node_count: total,
1332 ..Default::default()
1333 };
1334 s.nodes_by_label.insert(label.to_string(), total);
1335 if let Some(d) = distinct {
1336 s.node_distinct_values
1337 .insert((label.to_string(), "id".to_string()), d);
1338 }
1339 s
1340 }
1341
1342 fn person_labels() -> Vec<Vec<String>> {
1343 vec![vec!["Person".to_string()]]
1344 }
1345
1346 fn lit_int(v: i64) -> ResolvedExpr {
1347 ResolvedExpr::Literal(LiteralValue::Integer(v))
1348 }
1349
1350 fn lit_str(s: &str) -> ResolvedExpr {
1351 ResolvedExpr::Literal(LiteralValue::String(s.to_string()))
1352 }
1353
1354 fn lit_float(v: f64) -> ResolvedExpr {
1355 ResolvedExpr::Literal(LiteralValue::Float(v))
1356 }
1357
1358 fn point_lonlat(lon: f64, lat: f64) -> ResolvedExpr {
1359 ResolvedExpr::Function {
1360 function: lora_analyzer::FunctionId::builtin("cast.to")
1361 .expect("cast.to builtin exists"),
1362 distinct: false,
1363 args: vec![
1364 ResolvedExpr::Map(vec![
1365 ("longitude".to_string(), lit_float(lon)),
1366 ("latitude".to_string(), lit_float(lat)),
1367 ]),
1368 ResolvedExpr::Literal(LiteralValue::TypeName("POINT".to_string())),
1369 ],
1370 }
1371 }
1372
1373 #[test]
1376 fn score_label_scan_returns_label_count() {
1377 let stats = stats_with_label("Person", 1_000, None);
1378 let op = LogicalOp::NodeScan(NodeScan {
1379 input: None,
1380 var: VarId(0),
1381 labels: person_labels(),
1382 });
1383 assert_eq!(score_logical_op(&op, &stats), Some(1_000));
1384 }
1385
1386 #[test]
1387 fn score_property_scan_uses_distinct() {
1388 let stats = stats_with_label("Person", 1_000, Some(100));
1390 let op = LogicalOp::NodeByPropertyScan(NodeByPropertyScan {
1391 input: None,
1392 var: VarId(0),
1393 labels: person_labels(),
1394 key: "id".to_string(),
1395 value: lit_int(7),
1396 });
1397 assert_eq!(score_logical_op(&op, &stats), Some(10));
1398 }
1399
1400 #[test]
1401 fn score_property_scan_high_distinct_beats_label_scan() {
1402 let stats = stats_with_label("Person", 100, Some(100));
1405 let label_score = score_logical_op(
1406 &LogicalOp::NodeScan(NodeScan {
1407 input: None,
1408 var: VarId(0),
1409 labels: person_labels(),
1410 }),
1411 &stats,
1412 );
1413 let property_score = score_logical_op(
1414 &LogicalOp::NodeByPropertyScan(NodeByPropertyScan {
1415 input: None,
1416 var: VarId(0),
1417 labels: person_labels(),
1418 key: "id".to_string(),
1419 value: lit_int(7),
1420 }),
1421 &stats,
1422 );
1423 assert!(property_score < label_score);
1424 }
1425
1426 #[test]
1427 fn score_returns_none_without_label_stats() {
1428 let stats = GraphStats::default();
1432 let op = LogicalOp::NodeScan(NodeScan {
1433 input: None,
1434 var: VarId(0),
1435 labels: person_labels(),
1436 });
1437 assert_eq!(score_logical_op(&op, &stats), None);
1438 }
1439
1440 #[test]
1443 fn improves_over_legacy_fallback_when_both_unknown() {
1444 assert!(improves_over(None, None));
1447 }
1448
1449 #[test]
1450 fn improves_over_keeps_baseline_when_candidate_unknown() {
1451 assert!(!improves_over(None, Some(10)));
1452 }
1453
1454 #[test]
1455 fn improves_over_strictly_better_or_equal_wins() {
1456 assert!(improves_over(Some(5), Some(10)));
1457 assert!(improves_over(Some(10), Some(10)));
1458 assert!(!improves_over(Some(11), Some(10)));
1459 }
1460
1461 #[test]
1464 fn pick_best_candidate_picks_lowest_score() {
1465 let stats = stats_with_label("Person", 1_200, Some(100));
1466 let original = LogicalOp::NodeScan(NodeScan {
1467 input: None,
1468 var: VarId(0),
1469 labels: person_labels(),
1470 });
1471 let candidates = vec![
1473 LogicalOp::NodeByPropertyRangeScan(NodeByPropertyRangeScan {
1474 input: None,
1475 var: VarId(0),
1476 labels: person_labels(),
1477 key: "age".to_string(),
1478 lo: Some(lit_int(30)),
1479 lo_inclusive: false,
1480 hi: None,
1481 hi_inclusive: false,
1482 }),
1483 LogicalOp::NodeByPropertyScan(NodeByPropertyScan {
1484 input: None,
1485 var: VarId(0),
1486 labels: person_labels(),
1487 key: "id".to_string(),
1488 value: lit_int(7),
1489 }),
1490 ];
1491 let pick = pick_best_candidate(&original, candidates, &stats).expect("expected a pick");
1492 assert!(matches!(pick, LogicalOp::NodeByPropertyScan(_)));
1493 }
1494
1495 #[test]
1496 fn pick_best_candidate_returns_none_when_no_candidate_improves() {
1497 let mut stats = stats_with_label("Person", 1, None);
1503 stats.nodes_by_label.insert("Tiny".to_string(), 1);
1507 let original = LogicalOp::NodeScan(NodeScan {
1508 input: None,
1509 var: VarId(0),
1510 labels: vec![vec!["Tiny".to_string()]],
1511 });
1512 let candidates = vec![LogicalOp::NodeByPropertyScan(NodeByPropertyScan {
1516 input: None,
1517 var: VarId(0),
1518 labels: vec![vec!["Missing".to_string()]],
1519 key: "id".to_string(),
1520 value: lit_int(7),
1521 })];
1522 assert!(pick_best_candidate(&original, candidates, &stats).is_none());
1523 }
1524
1525 #[test]
1528 fn unbounded_low_range_is_tautological() {
1529 let bounds = RangeBounds {
1530 key: "age".to_string(),
1531 lo: Some(lit_int(i64::MIN)),
1532 lo_inclusive: false,
1533 hi: None,
1534 hi_inclusive: false,
1535 };
1536 assert!(is_tautological_range(&bounds));
1537 }
1538
1539 #[test]
1540 fn unbounded_high_range_is_tautological() {
1541 let bounds = RangeBounds {
1542 key: "age".to_string(),
1543 lo: None,
1544 lo_inclusive: false,
1545 hi: Some(lit_int(i64::MAX)),
1546 hi_inclusive: false,
1547 };
1548 assert!(is_tautological_range(&bounds));
1549 }
1550
1551 #[test]
1552 fn doubly_unbounded_range_is_tautological() {
1553 let bounds = RangeBounds {
1554 key: "age".to_string(),
1555 lo: Some(lit_int(i64::MIN)),
1556 lo_inclusive: false,
1557 hi: Some(lit_int(i64::MAX)),
1558 hi_inclusive: false,
1559 };
1560 assert!(is_tautological_range(&bounds));
1561 }
1562
1563 #[test]
1564 fn ordinary_range_is_not_tautological() {
1565 let bounds = RangeBounds {
1566 key: "age".to_string(),
1567 lo: Some(lit_int(0)),
1568 lo_inclusive: false,
1569 hi: Some(lit_int(100)),
1570 hi_inclusive: false,
1571 };
1572 assert!(!is_tautological_range(&bounds));
1573 }
1574
1575 #[test]
1576 fn empty_string_starts_with_is_tautological() {
1577 let candidate = TextCandidate {
1578 key: "name".to_string(),
1579 predicate: TextPredicate::StartsWith,
1580 query: lit_str(""),
1581 };
1582 assert!(is_tautological_text(&candidate));
1583 }
1584
1585 #[test]
1586 fn nonempty_string_starts_with_is_not_tautological() {
1587 let candidate = TextCandidate {
1588 key: "name".to_string(),
1589 predicate: TextPredicate::StartsWith,
1590 query: lit_str("A"),
1591 };
1592 assert!(!is_tautological_text(&candidate));
1593 }
1594
1595 #[test]
1596 fn world_bbox_is_tautological() {
1597 let candidate = PointCandidate {
1598 key: "loc".to_string(),
1599 predicate: PointPredicate::WithinBBox {
1600 lower_left: point_lonlat(-180.0, -90.0),
1601 upper_right: point_lonlat(180.0, 90.0),
1602 },
1603 };
1604 assert!(is_tautological_point(&candidate));
1605 }
1606
1607 #[test]
1608 fn city_bbox_is_not_tautological() {
1609 let candidate = PointCandidate {
1610 key: "loc".to_string(),
1611 predicate: PointPredicate::WithinBBox {
1612 lower_left: point_lonlat(4.7, 52.3),
1613 upper_right: point_lonlat(5.0, 52.5),
1614 },
1615 };
1616 assert!(!is_tautological_point(&candidate));
1617 }
1618
1619 #[test]
1620 fn distance_bbox_is_never_tautological() {
1621 let candidate = PointCandidate {
1624 key: "loc".to_string(),
1625 predicate: PointPredicate::WithinDistance {
1626 center: point_lonlat(0.0, 0.0),
1627 max_distance: lit_int(1_000_000_000),
1628 inclusive: true,
1629 },
1630 };
1631 assert!(!is_tautological_point(&candidate));
1632 }
1633}