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    /// All four distance comparisons prune (`<=`/`<` via min-distance, `>=`/`>` via max-distance);
179    /// `==`/`!=` are left to the scan.
180    #[rstest]
181    #[case(Operator::Lte, true)]
182    #[case(Operator::Lt, true)]
183    #[case(Operator::Gt, true)]
184    #[case(Operator::Gte, true)]
185    #[case(Operator::Eq, false)]
186    #[case(Operator::NotEq, false)]
187    fn prunes_distance_comparisons(
188        #[case] operator: Operator,
189        #[case] prunes: bool,
190    ) -> VortexResult<()> {
191        assert_eq!(falsify_distance(operator, true, 0.5)?.is_some(), prunes);
192        Ok(())
193    }
194
195    /// Distance is symmetric: both operand orders produce a proof.
196    #[rstest]
197    #[case(true)]
198    #[case(false)]
199    fn falsifies_either_operand_order(#[case] geom_first: bool) -> VortexResult<()> {
200        assert!(falsify_distance(Operator::Lte, geom_first, 0.5)?.is_some());
201        Ok(())
202    }
203
204    /// A NaN radius must not prune - the scan's total-order compare treats `dist <= NaN` as true.
205    #[test]
206    fn nan_radius_never_prunes() -> VortexResult<()> {
207        assert!(falsify_distance(Operator::Lte, true, f64::NAN)?.is_none());
208        Ok(())
209    }
210
211    /// A negative radius prunes every zone - vacuously sound: `distance <= r < 0` matches no row.
212    #[test]
213    fn negative_radius_prunes_vacuously() -> VortexResult<()> {
214        assert!(falsify_distance(Operator::Lte, true, -0.5)?.is_some());
215        Ok(())
216    }
217
218    /// Filter expressions arrive uncoerced, so `distance <= 10` may carry an integer literal -
219    /// it casts and prunes like an f64 radius.
220    #[test]
221    fn integer_radius_prunes() -> VortexResult<()> {
222        assert!(falsify_distance(Operator::Lte, true, 10i64)?.is_some());
223        Ok(())
224    }
225
226    /// A null radius has no value to reason about; the rule declines and the chunk is scanned.
227    #[test]
228    fn null_radius_never_prunes() -> VortexResult<()> {
229        let radius = Scalar::null(DType::Primitive(PType::F64, Nullability::Nullable));
230        assert!(falsify_distance(Operator::Lte, true, radius)?.is_none());
231        Ok(())
232    }
233
234    /// An extension-typed radius passes `Binary`'s typecheck (extension operands are exempt) but
235    /// has no numeric value - the rule declines rather than erroring, and the chunk is scanned.
236    #[test]
237    fn extension_radius_never_prunes() -> VortexResult<()> {
238        let session = geo_session();
239        let mut ctx = session.create_execution_ctx();
240
241        let geometry = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?;
242        assert!(falsify_distance(Operator::Lte, true, geometry)?.is_none());
243        Ok(())
244    }
245
246    /// A scope dtype without `GeometryAabb` support gets no proof - the stat reference would
247    /// fail to bind at prune time.
248    #[test]
249    fn unsupported_scope_is_not_pruned() -> VortexResult<()> {
250        let session = geo_session();
251        let mut ctx = session.create_execution_ctx();
252
253        let scope = DType::Primitive(PType::F64, Nullability::NonNullable);
254        let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?;
255        let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]);
256        let predicate = lt_eq(distance, lit(0.5f64));
257
258        let ctx = StatsRewriteCtx::new(&session, &scope);
259        assert!(GeoDistancePrune.falsify(&predicate, &ctx)?.is_none());
260        Ok(())
261    }
262
263    /// A comparison that does not wrap `GeoDistance` is left untouched.
264    #[test]
265    fn ignores_non_distance_comparison() -> VortexResult<()> {
266        let session = geo_session();
267        let scope = point_column(vec![0.0], vec![0.0])?.dtype().clone();
268
269        let predicate = lt_eq(lit(1.0f64), lit(2.0f64));
270        let ctx = StatsRewriteCtx::new(&session, &scope);
271        assert!(GeoDistancePrune.falsify(&predicate, &ctx)?.is_none());
272        Ok(())
273    }
274
275    /// End-to-end over a hand-built zone map: the far chunk is skipped, the near one kept.
276    #[test]
277    fn prunes_far_chunk_keeps_near() -> VortexResult<()> {
278        let session = geo_session();
279        let mut ctx = session.create_execution_ctx();
280
281        let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone();
282        // Chunk 0 near the origin (0,0..1,1), chunk 1 far away (100,100..101,101).
283        let zone_map = aabb_zone_map(
284            &point_dtype,
285            &[[0.0, 0.0, 1.0, 1.0], [100.0, 100.0, 101.0, 101.0]],
286        )?;
287
288        let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?;
289        let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]);
290        let predicate = lt_eq(distance, lit(0.5f64));
291        let proof = predicate
292            .falsify(&point_dtype, &session)?
293            .expect("distance filter should be falsifiable");
294
295        // `true` means the zone is pruned: chunk 0 (near origin) is kept, chunk 1 (far) is skipped.
296        let mask = zone_map.prune(&proof, &session)?;
297        assert_eq!(mask.iter().collect::<Vec<bool>>(), vec![false, true]);
298        Ok(())
299    }
300
301    /// The true-distance prune skips a chunk that is *diagonally* farther than `r`, even though
302    /// neither axis alone exceeds `r`, the case a per-axis box-overlap test would wrongly keep.
303    #[test]
304    fn prunes_diagonally_distant_chunk() -> VortexResult<()> {
305        let session = geo_session();
306        let mut ctx = session.create_execution_ctx();
307
308        let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone();
309        // One chunk, AABB (0.8,0.8)..(0.9,0.9): each axis is only 0.8 from the origin (<= r = 1), but
310        // the near corner is sqrt(0.8^2 + 0.8^2) ~= 1.13 away (> 1), so no point in the box is within 1.
311        let zone_map = aabb_zone_map(&point_dtype, &[[0.8, 0.8, 0.9, 0.9]])?;
312
313        let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?;
314        let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]);
315        let predicate = lt_eq(distance, lit(1.0f64));
316        let proof = predicate
317            .falsify(&point_dtype, &session)?
318            .expect("distance filter should be falsifiable");
319
320        assert_eq!(
321            zone_map
322                .prune(&proof, &session)?
323                .iter()
324                .collect::<Vec<bool>>(),
325            vec![true],
326        );
327        Ok(())
328    }
329
330    /// A `>= r` filter prunes a chunk lying wholly *within* `r` (every row nearer than `r`, so none
331    /// satisfy `>= r`) via the box max-distance, while a chunk beyond `r` is kept.
332    #[test]
333    fn prunes_within_chunk_for_far_filter() -> VortexResult<()> {
334        let session = geo_session();
335        let mut ctx = session.create_execution_ctx();
336
337        let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone();
338        // Chunk 0 (AABB 0,0..0.5,0.5, farthest corner ~= 0.707) is entirely within 2 of the origin;
339        // chunk 1 (100,100..101,101) is entirely beyond it.
340        let zone_map = aabb_zone_map(
341            &point_dtype,
342            &[[0.0, 0.0, 0.5, 0.5], [100.0, 100.0, 101.0, 101.0]],
343        )?;
344
345        let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?;
346        let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]);
347        let proof = gt_eq(distance, lit(2.0f64))
348            .falsify(&point_dtype, &session)?
349            .expect("distance filter should be falsifiable");
350
351        // Chunk 0 (within 2) is pruned for `>= 2`; chunk 1 (beyond 2) is kept.
352        let mask = zone_map.prune(&proof, &session)?;
353        assert_eq!(mask.iter().collect::<Vec<bool>>(), vec![true, false]);
354        Ok(())
355    }
356
357    /// Backward compat: a zone map written without the `GeometryAabb` stat (an older file) keeps
358    /// every zone, the missing stat binds to null and `null_as_false` retains the zone.
359    #[test]
360    fn missing_aabb_stat_keeps_all_zones() -> VortexResult<()> {
361        let session = geo_session();
362        let mut ctx = session.create_execution_ctx();
363
364        let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone();
365        let zone_map = empty_zone_map(&point_dtype)?;
366
367        let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?;
368        let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]);
369        let proof = lt_eq(distance, lit(0.5f64))
370            .falsify(&point_dtype, &session)?
371            .expect("distance filter should be falsifiable");
372
373        let mask = zone_map.prune(&proof, &session)?;
374        assert_eq!(mask.iter().collect::<Vec<bool>>(), vec![false, false]);
375        Ok(())
376    }
377}