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::BoundExpression;
8use vortex_array::scalar_fn::ScalarFnId;
9use vortex_array::scalar_fn::ScalarFnVTable;
10use vortex_array::scalar_fn::fns::binary::Binary;
11use vortex_array::scalar_fn::fns::literal::Literal;
12use vortex_array::scalar_fn::fns::operators::Operator;
13use vortex_array::stats::rewrite::StatsRewriteCtx;
14use vortex_array::stats::rewrite::StatsRewriteRule;
15use vortex_error::VortexResult;
16
17use super::aabb_stat;
18use super::geometry_and_constant;
19use super::gt;
20use super::gt_eq;
21use super::lit;
22use super::lt;
23use super::lt_eq;
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: &BoundExpression,
46        ctx: &StatsRewriteCtx<'_>,
47    ) -> VortexResult<Option<BoundExpression>> {
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: &BoundExpression,
98    query: GeoRect<f64>,
99    op: Operator,
100    radius: f64,
101) -> Option<BoundExpression> {
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::BoundExpression;
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<BoundExpression>> {
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
174            .new_expr(operator, [distance, lit(radius.into())])
175            .bind(&scope)?;
176
177        GeoDistancePrune.falsify(&predicate, &StatsRewriteCtx::new(&session))
178    }
179
180    /// A null geometry literal (`ST_Distance(geom, NULL) <= r`) declines cleanly instead of
181    /// erroring in the stats rewrite: the all-null predicate can never prune.
182    #[test]
183    fn null_literal_is_not_pruned() -> VortexResult<()> {
184        let session = geo_session();
185
186        let scope = point_column(vec![0.0], vec![0.0])?.dtype().clone();
187        let null_query = Scalar::null(scope.as_nullable());
188        let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(null_query)]);
189        let predicate = Binary
190            .new_expr(Operator::Lte, [distance, lit(0.5f64)])
191            .bind(&scope)?;
192
193        let ctx = StatsRewriteCtx::new(&session);
194        assert!(GeoDistancePrune.falsify(&predicate, &ctx)?.is_none());
195        Ok(())
196    }
197
198    /// All four distance comparisons prune (`<=`/`<` via min-distance, `>=`/`>` via max-distance);
199    /// `==`/`!=` are left to the scan.
200    #[rstest]
201    #[case(Operator::Lte, true)]
202    #[case(Operator::Lt, true)]
203    #[case(Operator::Gt, true)]
204    #[case(Operator::Gte, true)]
205    #[case(Operator::Eq, false)]
206    #[case(Operator::NotEq, false)]
207    fn prunes_distance_comparisons(
208        #[case] operator: Operator,
209        #[case] prunes: bool,
210    ) -> VortexResult<()> {
211        assert_eq!(falsify_distance(operator, true, 0.5)?.is_some(), prunes);
212        Ok(())
213    }
214
215    /// Distance is symmetric: both operand orders produce a proof.
216    #[rstest]
217    #[case(true)]
218    #[case(false)]
219    fn falsifies_either_operand_order(#[case] geom_first: bool) -> VortexResult<()> {
220        assert!(falsify_distance(Operator::Lte, geom_first, 0.5)?.is_some());
221        Ok(())
222    }
223
224    /// A NaN radius must not prune - the scan's total-order compare treats `dist <= NaN` as true.
225    #[test]
226    fn nan_radius_never_prunes() -> VortexResult<()> {
227        assert!(falsify_distance(Operator::Lte, true, f64::NAN)?.is_none());
228        Ok(())
229    }
230
231    /// A negative radius prunes every zone - vacuously sound: `distance <= r < 0` matches no row.
232    #[test]
233    fn negative_radius_prunes_vacuously() -> VortexResult<()> {
234        assert!(falsify_distance(Operator::Lte, true, -0.5)?.is_some());
235        Ok(())
236    }
237
238    /// An uncoerced integer radius is rejected while binding the comparison.
239    #[test]
240    fn uncoerced_integer_radius_fails_to_bind() -> VortexResult<()> {
241        assert!(falsify_distance(Operator::Lte, true, 10i64).is_err());
242        Ok(())
243    }
244
245    /// A null radius has no value to reason about; the rule declines and the chunk is scanned.
246    #[test]
247    fn null_radius_never_prunes() -> VortexResult<()> {
248        let radius = Scalar::null(DType::Primitive(PType::F64, Nullability::Nullable));
249        assert!(falsify_distance(Operator::Lte, true, radius)?.is_none());
250        Ok(())
251    }
252
253    /// An extension-typed radius passes `Binary`'s typecheck (extension operands are exempt) but
254    /// has no numeric value - the rule declines rather than erroring, and the chunk is scanned.
255    #[test]
256    fn extension_radius_never_prunes() -> VortexResult<()> {
257        let session = geo_session();
258        let mut ctx = session.create_execution_ctx();
259
260        let geometry = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?;
261        assert!(falsify_distance(Operator::Lte, true, geometry)?.is_none());
262        Ok(())
263    }
264
265    /// A non-geometry scope is rejected while binding, before stats rewriting.
266    #[test]
267    fn unsupported_scope_is_not_pruned() -> VortexResult<()> {
268        let session = geo_session();
269        let mut ctx = session.create_execution_ctx();
270
271        let scope = DType::Primitive(PType::F64, Nullability::NonNullable);
272        let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?;
273        let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]);
274        let predicate = lt_eq(distance, lit(0.5f64));
275        assert!(predicate.bind(&scope).is_err());
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)).bind(&scope)?;
286        let ctx = StatsRewriteCtx::new(&session);
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            .bind(&point_dtype)?
309            .falsify(&session)?
310            .expect("distance filter should be falsifiable");
311
312        // `true` means the zone is pruned: chunk 0 (near origin) is kept, chunk 1 (far) is skipped.
313        let mask = zone_map.prune(&proof, &session)?;
314        assert_eq!(mask.iter().collect::<Vec<bool>>(), vec![false, true]);
315        Ok(())
316    }
317
318    /// The true-distance prune skips a chunk that is *diagonally* farther than `r`, even though
319    /// neither axis alone exceeds `r`, the case a per-axis box-overlap test would wrongly keep.
320    #[test]
321    fn prunes_diagonally_distant_chunk() -> VortexResult<()> {
322        let session = geo_session();
323        let mut ctx = session.create_execution_ctx();
324
325        let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone();
326        // One chunk, AABB (0.8,0.8)..(0.9,0.9): each axis is only 0.8 from the origin (<= r = 1), but
327        // the near corner is sqrt(0.8^2 + 0.8^2) ~= 1.13 away (> 1), so no point in the box is within 1.
328        let zone_map = aabb_zone_map(&point_dtype, &[[0.8, 0.8, 0.9, 0.9]])?;
329
330        let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?;
331        let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]);
332        let predicate = lt_eq(distance, lit(1.0f64));
333        let proof = predicate
334            .bind(&point_dtype)?
335            .falsify(&session)?
336            .expect("distance filter should be falsifiable");
337
338        assert_eq!(
339            zone_map
340                .prune(&proof, &session)?
341                .iter()
342                .collect::<Vec<bool>>(),
343            vec![true],
344        );
345        Ok(())
346    }
347
348    /// A `>= r` filter prunes a chunk lying wholly *within* `r` (every row nearer than `r`, so none
349    /// satisfy `>= r`) via the box max-distance, while a chunk beyond `r` is kept.
350    #[test]
351    fn prunes_within_chunk_for_far_filter() -> VortexResult<()> {
352        let session = geo_session();
353        let mut ctx = session.create_execution_ctx();
354
355        let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone();
356        // Chunk 0 (AABB 0,0..0.5,0.5, farthest corner ~= 0.707) is entirely within 2 of the origin;
357        // chunk 1 (100,100..101,101) is entirely beyond it.
358        let zone_map = aabb_zone_map(
359            &point_dtype,
360            &[[0.0, 0.0, 0.5, 0.5], [100.0, 100.0, 101.0, 101.0]],
361        )?;
362
363        let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?;
364        let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]);
365        let proof = gt_eq(distance, lit(2.0f64))
366            .bind(&point_dtype)?
367            .falsify(&session)?
368            .expect("distance filter should be falsifiable");
369
370        // Chunk 0 (within 2) is pruned for `>= 2`; chunk 1 (beyond 2) is kept.
371        let mask = zone_map.prune(&proof, &session)?;
372        assert_eq!(mask.iter().collect::<Vec<bool>>(), vec![true, false]);
373        Ok(())
374    }
375
376    /// Backward compat: a zone map written without the `GeometryAabb` stat (an older file) keeps
377    /// every zone, the missing stat binds to null and `null_as_false` retains the zone.
378    #[test]
379    fn missing_aabb_stat_keeps_all_zones() -> VortexResult<()> {
380        let session = geo_session();
381        let mut ctx = session.create_execution_ctx();
382
383        let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone();
384        let zone_map = empty_zone_map(&point_dtype)?;
385
386        let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?;
387        let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]);
388        let proof = lt_eq(distance, lit(0.5f64))
389            .bind(&point_dtype)?
390            .falsify(&session)?
391            .expect("distance filter should be falsifiable");
392
393        let mask = zone_map.prune(&proof, &session)?;
394        assert_eq!(mask.iter().collect::<Vec<bool>>(), vec![false, false]);
395        Ok(())
396    }
397}