Skip to main content

vortex_geo/prune/
distance.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! `ST_Distance(geom, const) <op> radius` pruning.
5
6use geo::Rect as GeoRect;
7use vortex_array::expr::Expression;
8use vortex_array::expr::gt;
9use vortex_array::expr::gt_eq;
10use vortex_array::expr::lit;
11use vortex_array::expr::lt;
12use vortex_array::expr::lt_eq;
13use vortex_array::scalar_fn::ScalarFnId;
14use vortex_array::scalar_fn::ScalarFnVTable;
15use vortex_array::scalar_fn::fns::binary::Binary;
16use vortex_array::scalar_fn::fns::literal::Literal;
17use vortex_array::scalar_fn::fns::operators::Operator;
18use vortex_array::stats::rewrite::StatsRewriteCtx;
19use vortex_array::stats::rewrite::StatsRewriteRule;
20use vortex_error::VortexResult;
21
22use super::aabb_stat;
23use super::geometry_and_constant;
24use super::max_dist_sq;
25use super::min_dist_sq;
26use super::query_aabb;
27use crate::scalar_fn::distance::GeoDistance;
28
29/// Prunes chunks for `ST_Distance(geom, const) <op> r` filters.
30///
31/// All four comparisons prune: `<= r` / `< r` skip a chunk whose box is wholly beyond `r` (box
32/// min-distance); `>= r` / `> r` skip one wholly within `r` (box max-distance). `==` / `!=` don't
33/// prune.
34#[derive(Debug)]
35pub struct GeoDistancePrune;
36
37impl StatsRewriteRule for GeoDistancePrune {
38    fn scalar_fn_id(&self) -> ScalarFnId {
39        // The predicate root is the comparison, not `GeoDistance`, so key on `Binary`.
40        Binary.id()
41    }
42
43    fn falsify(
44        &self,
45        expr: &Expression,
46        ctx: &StatsRewriteCtx<'_>,
47    ) -> VortexResult<Option<Expression>> {
48        // Only the ordered comparisons prune today. `== r` could prune in the future (a chunk is
49        // provably empty when `r` lies outside its box's [min, max] distance interval), it's just
50        // not implemented. `!= r` cannot: pruning would need every row's distance to equal `r`,
51        // which an AABB can't prove.
52        let op = *expr.as_::<Binary>();
53        if !matches!(
54            op,
55            Operator::Lte | Operator::Lt | Operator::Gte | Operator::Gt
56        ) {
57            return Ok(None);
58        }
59
60        // The left operand must be `GeoDistance(geom, const)`; the right, the radius literal.
61        let distance = expr.child(0);
62        if distance.as_opt::<GeoDistance>().is_none() {
63            return Ok(None);
64        }
65        let Some(radius) = expr.child(1).as_opt::<Literal>() else {
66            return Ok(None);
67        };
68        // Casts any primitive radius (filters arrive uncoerced, so integer literals are
69        // legitimate); a null or non-primitive (e.g. extension-typed) literal has no value to
70        // reason about, decline and scan, never error.
71        let Ok(radius) = f64::try_from(radius) else {
72            return Ok(None);
73        };
74        // A NaN radius has no sound proof: `distance <op> NaN` is not a total order, so the chunk
75        // must be scanned, not pruned.
76        if radius.is_nan() {
77            return Ok(None);
78        }
79
80        let Some((geom, constant)) = geometry_and_constant(distance, ctx)? else {
81            return Ok(None);
82        };
83        let Some(query) = query_aabb(constant, ctx)? else {
84            return Ok(None);
85        };
86        Ok(distance_prune_proof(geom, query, op, radius))
87    }
88}
89
90/// Build the prune proof for `ST_Distance(geom, const) <op> radius`, or `None` when this
91/// operator/radius cannot prune. The box-to-box distance bounds every row's true distance for any
92/// geometry types: `<=` / `<` prune a chunk whose box is wholly beyond `radius` (min
93/// box-distance); `>=` / `>` prune one wholly within it (max box-distance).
94///
95/// A distance is always `>= 0`, which decides the degenerate radii up front.
96fn distance_prune_proof(
97    geom: &Expression,
98    query: GeoRect<f64>,
99    op: Operator,
100    radius: f64,
101) -> Option<Expression> {
102    // A distance is always non-negative, so degenerate radii resolve without touching the box.
103    match op {
104        // `<= r` / `< r` with a negative radius (or zero, for `<`) match nothing: prune every chunk.
105        Operator::Lte if radius < 0.0 => return Some(lit(true)),
106        Operator::Lt if radius <= 0.0 => return Some(lit(true)),
107        // `>= r` / `> r` with a negative radius (or zero, for `>=`) match all rows: never prune.
108        Operator::Gte if radius <= 0.0 => return None,
109        Operator::Gt if radius < 0.0 => return None,
110        _ => {}
111    }
112    // Compared squared to avoid a `sqrt`; all operands are `>= 0`.
113    let aabb = aabb_stat(geom);
114    let r2 = lit(radius * radius);
115    Some(match op {
116        // Beyond the threshold: even the nearest the box can be exceeds `r`.
117        Operator::Lte => gt(min_dist_sq(&aabb, query), r2),
118        Operator::Lt => gt_eq(min_dist_sq(&aabb, query), r2),
119        // Within the threshold: even the farthest the box can be is below `r`.
120        Operator::Gte => lt(max_dist_sq(&aabb, query), r2),
121        Operator::Gt => lt_eq(max_dist_sq(&aabb, query), r2),
122        _ => return None,
123    })
124}
125
126#[cfg(test)]
127mod tests {
128    use rstest::rstest;
129    use vortex_array::VortexSessionExecute;
130    use vortex_array::dtype::DType;
131    use vortex_array::dtype::Nullability;
132    use vortex_array::dtype::PType;
133    use vortex_array::expr::Expression;
134    use vortex_array::expr::gt_eq;
135    use vortex_array::expr::lit;
136    use vortex_array::expr::lt_eq;
137    use vortex_array::expr::root;
138    use vortex_array::scalar::Scalar;
139    use vortex_array::scalar_fn::EmptyOptions;
140    use vortex_array::scalar_fn::ScalarFnVTableExt;
141    use vortex_array::scalar_fn::fns::binary::Binary;
142    use vortex_array::scalar_fn::fns::operators::Operator;
143    use vortex_array::stats::rewrite::StatsRewriteCtx;
144    use vortex_array::stats::rewrite::StatsRewriteRule;
145    use vortex_error::VortexResult;
146
147    use super::GeoDistancePrune;
148    use crate::prune::test_harness::aabb_zone_map;
149    use crate::prune::test_harness::empty_zone_map;
150    use crate::scalar_fn::distance::GeoDistance;
151    use crate::test_harness::geo_session;
152    use crate::test_harness::point_column;
153
154    /// Run the rule against `GeoDistance(root, origin) <operator> radius`, operands swapped when
155    /// `geom_first` is false. The radius is any literal scalar, matching the uncoerced filter
156    /// expressions the rule sees in production.
157    fn falsify_distance(
158        operator: Operator,
159        geom_first: bool,
160        radius: impl Into<Scalar>,
161    ) -> VortexResult<Option<Expression>> {
162        let session = geo_session();
163        let mut ctx = session.create_execution_ctx();
164
165        let scope = point_column(vec![0.0], vec![0.0])?.dtype().clone();
166        let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?;
167        let operands = if geom_first {
168            [root(), lit(origin)]
169        } else {
170            [lit(origin), root()]
171        };
172        let distance = GeoDistance.new_expr(EmptyOptions, operands);
173        let predicate = Binary.new_expr(operator, [distance, lit(radius.into())]);
174
175        GeoDistancePrune.falsify(&predicate, &StatsRewriteCtx::new(&session, &scope))
176    }
177
178    /// A null geometry literal (`ST_Distance(geom, NULL) <= r`) declines cleanly instead of
179    /// erroring in the stats rewrite: the all-null predicate can never prune.
180    #[test]
181    fn null_literal_is_not_pruned() -> VortexResult<()> {
182        let session = geo_session();
183
184        let scope = point_column(vec![0.0], vec![0.0])?.dtype().clone();
185        let null_query = Scalar::null(scope.as_nullable());
186        let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(null_query)]);
187        let predicate = Binary.new_expr(Operator::Lte, [distance, lit(0.5f64)]);
188
189        let ctx = StatsRewriteCtx::new(&session, &scope);
190        assert!(GeoDistancePrune.falsify(&predicate, &ctx)?.is_none());
191        Ok(())
192    }
193
194    /// All four distance comparisons prune (`<=`/`<` via min-distance, `>=`/`>` via max-distance);
195    /// `==`/`!=` are left to the scan.
196    #[rstest]
197    #[case(Operator::Lte, true)]
198    #[case(Operator::Lt, true)]
199    #[case(Operator::Gt, true)]
200    #[case(Operator::Gte, true)]
201    #[case(Operator::Eq, false)]
202    #[case(Operator::NotEq, false)]
203    fn prunes_distance_comparisons(
204        #[case] operator: Operator,
205        #[case] prunes: bool,
206    ) -> VortexResult<()> {
207        assert_eq!(falsify_distance(operator, true, 0.5)?.is_some(), prunes);
208        Ok(())
209    }
210
211    /// Distance is symmetric: both operand orders produce a proof.
212    #[rstest]
213    #[case(true)]
214    #[case(false)]
215    fn falsifies_either_operand_order(#[case] geom_first: bool) -> VortexResult<()> {
216        assert!(falsify_distance(Operator::Lte, geom_first, 0.5)?.is_some());
217        Ok(())
218    }
219
220    /// A NaN radius must not prune - the scan's total-order compare treats `dist <= NaN` as true.
221    #[test]
222    fn nan_radius_never_prunes() -> VortexResult<()> {
223        assert!(falsify_distance(Operator::Lte, true, f64::NAN)?.is_none());
224        Ok(())
225    }
226
227    /// A negative radius prunes every zone - vacuously sound: `distance <= r < 0` matches no row.
228    #[test]
229    fn negative_radius_prunes_vacuously() -> VortexResult<()> {
230        assert!(falsify_distance(Operator::Lte, true, -0.5)?.is_some());
231        Ok(())
232    }
233
234    /// Filter expressions arrive uncoerced, so `distance <= 10` may carry an integer literal -
235    /// it casts and prunes like an f64 radius.
236    #[test]
237    fn integer_radius_prunes() -> VortexResult<()> {
238        assert!(falsify_distance(Operator::Lte, true, 10i64)?.is_some());
239        Ok(())
240    }
241
242    /// A null radius has no value to reason about; the rule declines and the chunk is scanned.
243    #[test]
244    fn null_radius_never_prunes() -> VortexResult<()> {
245        let radius = Scalar::null(DType::Primitive(PType::F64, Nullability::Nullable));
246        assert!(falsify_distance(Operator::Lte, true, radius)?.is_none());
247        Ok(())
248    }
249
250    /// An extension-typed radius passes `Binary`'s typecheck (extension operands are exempt) but
251    /// has no numeric value - the rule declines rather than erroring, and the chunk is scanned.
252    #[test]
253    fn extension_radius_never_prunes() -> VortexResult<()> {
254        let session = geo_session();
255        let mut ctx = session.create_execution_ctx();
256
257        let geometry = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?;
258        assert!(falsify_distance(Operator::Lte, true, geometry)?.is_none());
259        Ok(())
260    }
261
262    /// A scope dtype without `GeometryAabb` support gets no proof - the stat reference would
263    /// fail to bind at prune time.
264    #[test]
265    fn unsupported_scope_is_not_pruned() -> VortexResult<()> {
266        let session = geo_session();
267        let mut ctx = session.create_execution_ctx();
268
269        let scope = DType::Primitive(PType::F64, Nullability::NonNullable);
270        let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?;
271        let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]);
272        let predicate = lt_eq(distance, lit(0.5f64));
273
274        let ctx = StatsRewriteCtx::new(&session, &scope);
275        assert!(GeoDistancePrune.falsify(&predicate, &ctx)?.is_none());
276        Ok(())
277    }
278
279    /// A comparison that does not wrap `GeoDistance` is left untouched.
280    #[test]
281    fn ignores_non_distance_comparison() -> VortexResult<()> {
282        let session = geo_session();
283        let scope = point_column(vec![0.0], vec![0.0])?.dtype().clone();
284
285        let predicate = lt_eq(lit(1.0f64), lit(2.0f64));
286        let ctx = StatsRewriteCtx::new(&session, &scope);
287        assert!(GeoDistancePrune.falsify(&predicate, &ctx)?.is_none());
288        Ok(())
289    }
290
291    /// End-to-end over a hand-built zone map: the far chunk is skipped, the near one kept.
292    #[test]
293    fn prunes_far_chunk_keeps_near() -> VortexResult<()> {
294        let session = geo_session();
295        let mut ctx = session.create_execution_ctx();
296
297        let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone();
298        // Chunk 0 near the origin (0,0..1,1), chunk 1 far away (100,100..101,101).
299        let zone_map = aabb_zone_map(
300            &point_dtype,
301            &[[0.0, 0.0, 1.0, 1.0], [100.0, 100.0, 101.0, 101.0]],
302        )?;
303
304        let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?;
305        let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]);
306        let predicate = lt_eq(distance, lit(0.5f64));
307        let proof = predicate
308            .falsify(&point_dtype, &session)?
309            .expect("distance filter should be falsifiable");
310
311        // `true` means the zone is pruned: chunk 0 (near origin) is kept, chunk 1 (far) is skipped.
312        let mask = zone_map.prune(&proof, &session)?;
313        assert_eq!(mask.iter().collect::<Vec<bool>>(), vec![false, true]);
314        Ok(())
315    }
316
317    /// The true-distance prune skips a chunk that is *diagonally* farther than `r`, even though
318    /// neither axis alone exceeds `r`, the case a per-axis box-overlap test would wrongly keep.
319    #[test]
320    fn prunes_diagonally_distant_chunk() -> VortexResult<()> {
321        let session = geo_session();
322        let mut ctx = session.create_execution_ctx();
323
324        let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone();
325        // One chunk, AABB (0.8,0.8)..(0.9,0.9): each axis is only 0.8 from the origin (<= r = 1), but
326        // the near corner is sqrt(0.8^2 + 0.8^2) ~= 1.13 away (> 1), so no point in the box is within 1.
327        let zone_map = aabb_zone_map(&point_dtype, &[[0.8, 0.8, 0.9, 0.9]])?;
328
329        let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?;
330        let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]);
331        let predicate = lt_eq(distance, lit(1.0f64));
332        let proof = predicate
333            .falsify(&point_dtype, &session)?
334            .expect("distance filter should be falsifiable");
335
336        assert_eq!(
337            zone_map
338                .prune(&proof, &session)?
339                .iter()
340                .collect::<Vec<bool>>(),
341            vec![true],
342        );
343        Ok(())
344    }
345
346    /// A `>= r` filter prunes a chunk lying wholly *within* `r` (every row nearer than `r`, so none
347    /// satisfy `>= r`) via the box max-distance, while a chunk beyond `r` is kept.
348    #[test]
349    fn prunes_within_chunk_for_far_filter() -> VortexResult<()> {
350        let session = geo_session();
351        let mut ctx = session.create_execution_ctx();
352
353        let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone();
354        // Chunk 0 (AABB 0,0..0.5,0.5, farthest corner ~= 0.707) is entirely within 2 of the origin;
355        // chunk 1 (100,100..101,101) is entirely beyond it.
356        let zone_map = aabb_zone_map(
357            &point_dtype,
358            &[[0.0, 0.0, 0.5, 0.5], [100.0, 100.0, 101.0, 101.0]],
359        )?;
360
361        let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?;
362        let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]);
363        let proof = gt_eq(distance, lit(2.0f64))
364            .falsify(&point_dtype, &session)?
365            .expect("distance filter should be falsifiable");
366
367        // Chunk 0 (within 2) is pruned for `>= 2`; chunk 1 (beyond 2) is kept.
368        let mask = zone_map.prune(&proof, &session)?;
369        assert_eq!(mask.iter().collect::<Vec<bool>>(), vec![true, false]);
370        Ok(())
371    }
372
373    /// Backward compat: a zone map written without the `GeometryAabb` stat (an older file) keeps
374    /// every zone, the missing stat binds to null and `null_as_false` retains the zone.
375    #[test]
376    fn missing_aabb_stat_keeps_all_zones() -> VortexResult<()> {
377        let session = geo_session();
378        let mut ctx = session.create_execution_ctx();
379
380        let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone();
381        let zone_map = empty_zone_map(&point_dtype)?;
382
383        let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?;
384        let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]);
385        let proof = lt_eq(distance, lit(0.5f64))
386            .falsify(&point_dtype, &session)?
387            .expect("distance filter should be falsifiable");
388
389        let mask = zone_map.prune(&proof, &session)?;
390        assert_eq!(mask.iter().collect::<Vec<bool>>(), vec![false, false]);
391        Ok(())
392    }
393}