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::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
400fn 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
475fn 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
559fn 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
593fn 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
615fn 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 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 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 Some(base.div_ceil(5))
657 }
658 LogicalOp::Filter(_) => None,
659 LogicalOp::Expand(expand) => {
660 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 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
726fn 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
776fn is_world_bbox(lower_left: &ResolvedExpr, upper_right: &ResolvedExpr) -> bool {
781 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
856fn 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 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 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 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 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 #[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 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 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 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 #[test]
1436 fn improves_over_legacy_fallback_when_both_unknown() {
1437 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 #[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 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 let mut stats = stats_with_label("Person", 1, None);
1496 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 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 #[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 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}