diff --git a/Cargo.toml b/Cargo.toml
index 65452f18300..fc38587579e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -162,7 +162,13 @@ flatbuffers = "25.2.10"
fsst-rs = "0.6.0"
futures = { version = "0.3.31", default-features = false }
fuzzy-matcher = "0.3"
-geo = "0.31.0"
+# `vortex-spatial`'s `contains_route` transcribes geo's `impl_contains_from_relate!` dispatch
+# table, so any bump that moves a row silently changes containment verdicts. The tests stay green
+# wherever relate and the direct algorithm agree. Pinned exactly so that taking any new geo,
+# patch releases included, is a deliberate edit of this line that re-verifies the table; a caret
+# requirement would let `cargo update` (or automated lockfile maintenance) take 0.31.x with no diff
+# to review. See `vortex-spatial/src/scalar_fn/contains.rs`.
+geo = "=0.31.0"
geo-traits = "0.3.0"
geo-types = "0.7.19"
geoarrow = "0.8.0"
diff --git a/vortex-spatial/benches/binary_predicates.rs b/vortex-spatial/benches/binary_predicates.rs
index b84ab67b17c..281578e4e16 100644
--- a/vortex-spatial/benches/binary_predicates.rs
+++ b/vortex-spatial/benches/binary_predicates.rs
@@ -12,11 +12,6 @@
//! column-x-column arms are the control: no operand is constant, so a prepared path has nothing to
//! hoist and must not regress them.
//!
-//! `contains` has no all-overlapping arm. One `contains(query polygon, contained square)` row
-//! builds a topology graph over the constant's 128 edges, which CodSpeed's CPU simulation charges
-//! around 120 µs, so no row count both fits the per-iteration budget and exercises the row loop.
-//! [`intersects::polygons_overlapping_x_constant`] covers the never-rejects case instead.
-//!
//! Run with `cargo bench -p vortex-spatial --bench binary_predicates`.
#![expect(clippy::unwrap_used)]
@@ -64,6 +59,10 @@ const ROWS: usize = 1 << 7;
/// pairwise predicate. It needs a smaller fixture than [`ROWS`] to stay inside the same budget.
const OVERLAPPING_POLYGON_ROWS: usize = 1 << 5;
+/// Containment builds a topology graph for each polygon pair. Four rows fit the benchmark budget
+/// while exercising construction followed by reuse of the prepared constant geometry.
+const CONTAINED_POLYGON_ROWS: usize = 4;
+
/// Deterministic pseudo-random value in `[0, 1)`.
fn unit(i: usize) -> f64 {
((i.wrapping_mul(2654435761) >> 8) % 10_000) as f64 / 10_000.0
@@ -225,6 +224,23 @@ mod contains {
});
}
+ /// Constant container against contained polygons: every bbox check passes, the first row
+ /// prepares the constant geometry, and the remaining rows reuse it for the full predicate.
+ #[divan::bench]
+ fn constant_x_polygons_overlapping(bencher: Bencher) {
+ let mut ctx = SESSION.create_execution_ctx();
+ let query = query_constant(&mut ctx, CONTAINED_POLYGON_ROWS);
+ let polygons = squares_mostly_overlapping(CONTAINED_POLYGON_ROWS);
+ bencher
+ .counter(ItemsCount::new(CONTAINED_POLYGON_ROWS))
+ .bench_local(|| {
+ execute(
+ SpatialContains::try_new_array(query.clone(), polygons.clone()),
+ &mut ctx,
+ )
+ });
+ }
+
/// Constant container against a point column with one null row in eight.
#[divan::bench]
fn constant_x_nullable_points(bencher: Bencher) {
diff --git a/vortex-spatial/src/scalar_fn/contains.rs b/vortex-spatial/src/scalar_fn/contains.rs
index 8850e59f751..9b22a471204 100644
--- a/vortex-spatial/src/scalar_fn/contains.rs
+++ b/vortex-spatial/src/scalar_fn/contains.rs
@@ -3,44 +3,31 @@
//! `ST_Contains`: OGC containment test between two native geometries.
+use std::cell::OnceCell;
+
+use geo::BoundingRect;
use geo::Contains;
+use geo::PreparedGeometry;
+use geo::Relate;
+use geo_types::Geometry;
+use geo_types::Rect;
use vortex_array::ArrayRef;
-use vortex_array::ExecutionCtx;
use vortex_array::arrays::ScalarFnArray;
use vortex_array::dtype::DType;
-use vortex_array::dtype::Nullability;
-use vortex_array::expr::Expression;
-use vortex_array::expr::union_child_validities;
-use vortex_array::scalar_fn::Arity;
-use vortex_array::scalar_fn::ChildName;
use vortex_array::scalar_fn::EmptyOptions;
-use vortex_array::scalar_fn::ExecutionArgs;
use vortex_array::scalar_fn::ScalarFnId;
-use vortex_array::scalar_fn::ScalarFnVTable;
use vortex_array::scalar_fn::TypedScalarFnInstance;
+use vortex_array::scalar_fn::unstable::row::InitializedElement;
+use vortex_array::scalar_fn::unstable::row::RowFn;
+use vortex_array::scalar_fn::unstable::row::RowVisitor;
+use vortex_array::scalar_fn::unstable::row::UninitElementSink;
use vortex_error::VortexResult;
-use vortex_error::vortex_ensure;
use vortex_session::VortexSession;
use vortex_session::registry::CachedId;
-use crate::extension::is_native_geometry;
-use crate::scalar_fn::execute::execute_binary_geo_types;
-
-/// Validate the two native geometry operands accepted by `ST_Contains`.
-fn validate_contains_operands(dtypes: &[DType]) -> VortexResult<()> {
- vortex_ensure!(
- dtypes.len() == 2,
- "spatial: contains requires exactly two geometry operands, got {}",
- dtypes.len()
- );
- for dtype in dtypes {
- vortex_ensure!(
- is_native_geometry(dtype),
- "spatial: contains operand {dtype} is not a native geometry type"
- );
- }
- Ok(())
-}
+use crate::scalar_fn::row::GeometryRow;
+#[cfg(test)]
+use crate::scalar_fn::row::probe;
/// OGC `ST_Contains` between two native geometry operands, each a column or a constant
/// literal: true where operand `b` lies completely inside operand `a` (boundary contact alone
@@ -59,83 +46,314 @@ impl SpatialContains {
}
}
-impl ScalarFnVTable for SpatialContains {
+impl RowFn for SpatialContains {
type Options = EmptyOptions;
+ const ARG_NAMES: &'static [&'static str] = &["a", "b"];
+ const FALLIBLE: bool = true;
+
fn id(&self) -> ScalarFnId {
static ID: CachedId = CachedId::new("vortex.st.contains");
*ID
}
- fn serialize(&self, _: &Self::Options) -> VortexResult>> {
+ fn serialize(&self, _options: &Self::Options) -> VortexResult >> {
Ok(Some(vec![]))
}
- fn deserialize(&self, _: &[u8], _: &VortexSession) -> VortexResult {
+ fn deserialize(
+ &self,
+ _metadata: &[u8],
+ _session: &VortexSession,
+ ) -> VortexResult {
Ok(EmptyOptions)
}
- fn arity(&self, _: &Self::Options) -> Arity {
- Arity::Exact(2)
+ /// Containment is not symmetric, so `a` is always the container and `b` the contained.
+ fn dispatch>(
+ &self,
+ _options: &Self::Options,
+ _args: &[DType],
+ visitor: V,
+ ) -> VortexResult {
+ visitor.visit_prepared_into::<(GeometryRow, GeometryRow), UninitElementSink, _, _>(
+ |(a, b)| {
+ #[cfg(test)]
+ probe::record(a.is_some(), b.is_some());
+ ConstOperands {
+ a: a.map(PreparedOperand::new),
+ b: b.map(PreparedOperand::new),
+ }
+ },
+ |operands, (a, b), output| {
+ // SAFETY: `output` is the `UninitElementSink` row supplied for this callback.
+ unsafe { InitializedElement::write(output, contains_row_prepared(operands, a, b)) }
+ },
+ )
}
+}
+
+/// Per-batch state for the contains row kernel: the prepared form of whichever operand is
+/// constant for the batch. `None` marks an operand that varies by row.
+struct ConstOperands {
+ /// Operand `a` (the container) when it is batch-constant.
+ a: Option,
+
+ /// Operand `b` (the contained) when it is batch-constant.
+ b: Option,
+}
+
+/// One batch-constant operand: its bounding rectangle and the [`PreparedGeometry`] built on the
+/// first row whose pairing routes through relate.
+///
+/// The build is lazy because preparation (self-noding the topology graph plus an R*-tree over the
+/// edges) costs `O(edges log edges)` and pays off only on relate-routed pairings; a batch of
+/// point rows against a constant polygon never touches it, and preparing a large constant eagerly
+/// would charge such a batch for nothing.
+struct PreparedOperand {
+ /// The constant's bounding rectangle, folded once for conservative row rejection.
+ bbox: Option>,
+
+ /// The constant's prepared form, initialized only when a relate route needs it.
+ prepared: OnceCell, f64>>,
+}
- fn child_name(&self, _: &Self::Options, child_idx: usize) -> ChildName {
- match child_idx {
- 0 => ChildName::from("a"),
- 1 => ChildName::from("b"),
- _ => unreachable!("contains has exactly two children"),
+impl PreparedOperand {
+ fn new(geometry: &Geometry) -> Self {
+ Self {
+ bbox: finite_bounding_rect(geometry),
+ prepared: OnceCell::new(),
}
}
- fn return_dtype(&self, _: &Self::Options, dtypes: &[DType]) -> VortexResult {
- validate_contains_operands(dtypes)?;
- let nullability = Nullability::from(dtypes.iter().any(DType::is_nullable));
- Ok(DType::Bool(nullability))
+ /// Return the prepared geometry, cloning the decoded constant only on first use.
+ ///
+ /// `geometry` **must** be the constant represented by this state. The row kernel maintains
+ /// that relationship by passing the operand from the same decoded constant column that
+ /// produced this [`PreparedOperand`].
+ fn get(&self, geometry: &Geometry) -> &PreparedGeometry<'static, Geometry, f64> {
+ self.prepared
+ .get_or_init(|| PreparedGeometry::from(geometry.clone()))
}
+}
- fn execute(
- &self,
- _: &Self::Options,
- args: &dyn ExecutionArgs,
- ctx: &mut ExecutionCtx,
- ) -> VortexResult {
- let a = args.get(0)?;
- let b = args.get(1)?;
- // Containment is not symmetric: `a` is always the container and `b` the contained. A
- // container's rect must cover the contained's rect (`Rect::contains` is the closed
- // test), so a contained rect poking outside proves the row false.
- execute_binary_geo_types(
- &a,
- &b,
- |a, b| a.contains(b),
- Some(|ra, rb| (!ra.contains(rb)).then_some(false)),
- ctx,
- )
- }
+/// Returns a bounding rectangle only when ordered comparisons can conservatively reject a row.
+///
+/// Geo permits non-finite coordinates. A rectangle containing NaN cannot prove non-containment,
+/// because its ordered comparisons can return false even when the exact algorithm accepts the
+/// geometry.
+fn finite_bounding_rect(geometry: &Geometry) -> Option> {
+ let bbox = geometry.bounding_rect()?;
+ let min = bbox.min();
+ let max = bbox.max();
+
+ [min.x, min.y, max.x, max.y]
+ .into_iter()
+ .all(f64::is_finite)
+ .then_some(bbox)
+}
- fn validity(
- &self,
- _: &Self::Options,
- expression: &Expression,
- ) -> VortexResult> {
- union_child_validities(expression)
+/// How geo's `a.contains(b)` computes its verdict for a pairing.
+enum ContainsRoute {
+ /// `a.relate(b).is_contains()`.
+ ForwardRelate,
+
+ /// `b.relate(a).is_within()`, how geo phrases relate for `MultiPolygon` containers.
+ ReversedRelate,
+
+ /// A direct algorithm (coordinate position, point arithmetic); nothing to prepare.
+ Direct,
+}
+
+/// The route geo 0.31's `Contains` dispatch takes for `a.contains(b)`.
+///
+/// The prepared substitution in [`contains_row_prepared`] **must** run relate exactly where geo
+/// runs relate, with the same argument order, because geo's direct algorithms are not everywhere
+/// bit-identical to a relate matrix query (they resolve degenerate and boundary cases with
+/// different arithmetic). The relate rows below transcribe geo's `impl_contains_from_relate!`
+/// lists per container type; everything else, notably every `Point`/`MultiPoint` contained side
+/// and every `Point` container, is direct.
+///
+/// **This table is coupled to the geo version.** It transcribes a dispatch that geo is free to
+/// reshuffle in any release, and a wrong row is a silently wrong verdict rather than a build error.
+/// The workspace therefore pins `geo = "=0.31.0"`: taking any new geo, patch releases included, is
+/// a deliberate edit of that line, and the edit must re-verify this table against
+/// `impl_contains_from_relate!`.
+///
+/// `constant_operands_agree_with_columns` is the mechanical check, and it is **not** complete: it
+/// compares the prepared route against plain `a.contains(b)` only for the container types it has
+/// cases for. `routes_agree_with_geo_for_every_container` covers the rest, one representative
+/// pairing per container variant, and is the one to extend when geo grows a geometry type. Both
+/// stay green wherever relate and the direct algorithm agree, so neither replaces the pin.
+fn contains_route(a: &Geometry, b: &Geometry) -> ContainsRoute {
+ use Geometry as G;
+
+ match (a, b) {
+ // Line contains [Polygon, MultiLineString, MultiPolygon, GeometryCollection, Rect,
+ // Triangle].
+ (
+ G::Line(_),
+ G::Polygon(_)
+ | G::MultiLineString(_)
+ | G::MultiPolygon(_)
+ | G::GeometryCollection(_)
+ | G::Rect(_)
+ | G::Triangle(_),
+ )
+ // LineString contains [Polygon, MultiPoint, MultiLineString, MultiPolygon,
+ // GeometryCollection, Rect, Triangle].
+ | (
+ G::LineString(_),
+ G::Polygon(_)
+ | G::MultiPoint(_)
+ | G::MultiLineString(_)
+ | G::MultiPolygon(_)
+ | G::GeometryCollection(_)
+ | G::Rect(_)
+ | G::Triangle(_),
+ )
+ // MultiLineString contains everything except Point.
+ | (
+ G::MultiLineString(_),
+ G::Line(_)
+ | G::LineString(_)
+ | G::Polygon(_)
+ | G::MultiPoint(_)
+ | G::MultiLineString(_)
+ | G::MultiPolygon(_)
+ | G::GeometryCollection(_)
+ | G::Rect(_)
+ | G::Triangle(_),
+ )
+ // MultiPoint contains [Line, LineString, Polygon, MultiLineString, MultiPolygon,
+ // GeometryCollection, Rect, Triangle].
+ | (
+ G::MultiPoint(_),
+ G::Line(_)
+ | G::LineString(_)
+ | G::Polygon(_)
+ | G::MultiLineString(_)
+ | G::MultiPolygon(_)
+ | G::GeometryCollection(_)
+ | G::Rect(_)
+ | G::Triangle(_),
+ )
+ // Polygon contains everything except Point and MultiPoint.
+ | (
+ G::Polygon(_),
+ G::Line(_)
+ | G::LineString(_)
+ | G::Polygon(_)
+ | G::MultiLineString(_)
+ | G::MultiPolygon(_)
+ | G::GeometryCollection(_)
+ | G::Rect(_)
+ | G::Triangle(_),
+ )
+ // Rect contains [Line, LineString, MultiPoint, MultiLineString, MultiPolygon,
+ // GeometryCollection, Triangle]; Rect contains Rect and Polygon are direct.
+ | (
+ G::Rect(_),
+ G::Line(_)
+ | G::LineString(_)
+ | G::MultiPoint(_)
+ | G::MultiLineString(_)
+ | G::MultiPolygon(_)
+ | G::GeometryCollection(_)
+ | G::Triangle(_),
+ )
+ // Triangle and GeometryCollection contain everything except Point.
+ | (
+ G::Triangle(_) | G::GeometryCollection(_),
+ G::Line(_)
+ | G::LineString(_)
+ | G::Polygon(_)
+ | G::MultiPoint(_)
+ | G::MultiLineString(_)
+ | G::MultiPolygon(_)
+ | G::GeometryCollection(_)
+ | G::Rect(_)
+ | G::Triangle(_),
+ ) => ContainsRoute::ForwardRelate,
+
+ // MultiPolygon contains everything except Point and MultiPoint, phrased reversed.
+ (
+ G::MultiPolygon(_),
+ G::Line(_)
+ | G::LineString(_)
+ | G::Polygon(_)
+ | G::MultiLineString(_)
+ | G::MultiPolygon(_)
+ | G::GeometryCollection(_)
+ | G::Rect(_)
+ | G::Triangle(_),
+ ) => ContainsRoute::ReversedRelate,
+
+ _ => ContainsRoute::Direct,
}
+}
- fn is_strict(&self, _: &Self::Options) -> bool {
- true
+/// Computes one row of contains, substituting a prepared graph for a constant operand on the
+/// pairings geo itself answers through relate.
+///
+/// [`PreparedGeometry`] carries the operand's self-noded topology graph and edge R*-tree, so a
+/// relate against it skips rebuilding both and reads its bounding rect from cache; geo asserts
+/// the cached graph equal to a freshly built one (its `swap_arg_index` test), which is what makes
+/// the substitution result-preserving. Before dispatch, a disjoint constant-side bounding rect
+/// conservatively rejects the row, matching the columnar implementation's #9076 optimization.
+/// All other rows delegate to the same direct or relate route as `a.contains(b)`.
+fn contains_row_prepared(operands: &ConstOperands, a: &Geometry, b: &Geometry) -> bool {
+ let rejected = match (&operands.a, &operands.b) {
+ (None, None) => false,
+ (Some(const_a), Some(const_b)) => const_a
+ .bbox
+ .zip(const_b.bbox)
+ .is_some_and(|(bbox_a, bbox_b)| !bbox_a.contains(&bbox_b)),
+ (Some(const_a), None) => const_a
+ .bbox
+ .zip(finite_bounding_rect(b))
+ .is_some_and(|(bbox_a, bbox_b)| !bbox_a.contains(&bbox_b)),
+ (None, Some(const_b)) => finite_bounding_rect(a)
+ .zip(const_b.bbox)
+ .is_some_and(|(bbox_a, bbox_b)| !bbox_a.contains(&bbox_b)),
+ };
+
+ if rejected {
+ return false;
}
- fn is_fallible(&self, _: &Self::Options) -> bool {
- false
+ match contains_route(a, b) {
+ ContainsRoute::Direct => a.contains(b),
+ ContainsRoute::ForwardRelate => match (&operands.a, &operands.b) {
+ (Some(const_a), Some(const_b)) => const_a.get(a).relate(const_b.get(b)).is_contains(),
+ (Some(const_a), None) => const_a.get(a).relate(b).is_contains(),
+ (None, Some(const_b)) => a.relate(const_b.get(b)).is_contains(),
+ (None, None) => a.contains(b),
+ },
+ ContainsRoute::ReversedRelate => match (&operands.a, &operands.b) {
+ (Some(const_a), Some(const_b)) => const_b.get(b).relate(const_a.get(a)).is_within(),
+ (Some(const_a), None) => b.relate(const_a.get(a)).is_within(),
+ (None, Some(const_b)) => const_b.get(b).relate(a).is_within(),
+ (None, None) => a.contains(b),
+ },
}
}
#[cfg(test)]
mod tests {
+ use geo::Contains;
+ use geo_types::Coord;
use geo_types::Geometry;
+ use geo_types::GeometryCollection;
+ use geo_types::Line;
use geo_types::LineString;
+ use geo_types::MultiLineString;
+ use geo_types::MultiPoint;
+ use geo_types::MultiPolygon;
use geo_types::Point;
use geo_types::Polygon;
+ use geo_types::Rect;
+ use geo_types::Triangle;
use rstest::rstest;
use vortex_array::ArrayRef;
use vortex_array::Canonical;
@@ -144,6 +362,7 @@ mod tests {
use vortex_array::VortexSessionExecute;
use vortex_array::arrays::BoolArray;
use vortex_array::arrays::ConstantArray;
+ use vortex_array::arrays::MaskedArray;
use vortex_array::assert_arrays_eq;
use vortex_array::dtype::DType;
use vortex_array::dtype::Nullability;
@@ -158,10 +377,15 @@ mod tests {
use vortex_error::vortex_err;
use wkb::writer::WriteOptions;
+ use super::ConstOperands;
+ use super::PreparedOperand;
use super::SpatialContains;
+ use super::contains_row_prepared;
+ use crate::scalar_fn::row::probe::assert_prepared_agrees_with_columns;
use crate::test_harness::linestring_column;
use crate::test_harness::nullable_point_column;
use crate::test_harness::point_column;
+ use crate::test_harness::polygon_column;
/// A rectangle polygon with corners `(x0, y0)` and `(x1, y1)`, no holes.
fn rect_polygon(x0: f64, y0: f64, x1: f64, y1: f64) -> Polygon {
@@ -218,6 +442,21 @@ mod tests {
assert_contains(container, other, [expected; 3])
}
+ /// A non-finite bounding rectangle cannot reject a containment that the exact geometry
+ /// algorithm accepts.
+ #[test]
+ fn nan_bounding_rect_does_not_reject_containment() {
+ let container = multipoint(vec![(f64::NAN, f64::NAN), (1.0, 1.0)]);
+ let contained = point(1.0, 1.0);
+ let operands = ConstOperands {
+ a: Some(PreparedOperand::new(&container)),
+ b: Some(PreparedOperand::new(&contained)),
+ };
+
+ assert!(container.contains(&contained));
+ assert!(contains_row_prepared(&operands, &container, &contained));
+ }
+
/// Partially overlapping polygons contain each other in neither direction.
#[test]
fn overlapping_polygons_contain_neither_way() -> VortexResult<()> {
@@ -246,6 +485,20 @@ mod tests {
assert_contains(container, points, [true, false, false])
}
+ /// Constant container vs a linestring column: a row whose bounding rect pokes outside the
+ /// container's is not contained, while one wholly inside is. Carried over from the columnar
+ /// bounding-rect rejection in #9076, since it constrains the verdict rather than the mechanism.
+ #[test]
+ fn constant_container_vs_row_rect_poking_outside() -> VortexResult<()> {
+ let container = geometry_constant(&Geometry::Polygon(rect_polygon(0.0, 0.0, 4.0, 4.0)), 3)?;
+ let lines = linestring_column(vec![
+ vec![(1.0, 1.0), (3.0, 3.0)],
+ vec![(1.0, 1.0), (9.0, 1.0)],
+ vec![(5.0, 5.0), (9.0, 9.0)],
+ ])?;
+ assert_contains(container, lines, [true, false, false])
+ }
+
/// Polygon column vs constant point: only the polygon around the point contains it.
#[test]
fn polygon_column_vs_constant_point() -> VortexResult<()> {
@@ -266,20 +519,6 @@ mod tests {
assert_contains(away, point, [false; 2])
}
- /// Constant container vs a linestring column: a row whose bounding rect pokes outside the
- /// container's rect is proven false by the rect pre-check alone; a fully inside row still
- /// needs (and passes) the exact test.
- #[test]
- fn constant_container_vs_row_rect_poking_outside() -> VortexResult<()> {
- let container = geometry_constant(&Geometry::Polygon(rect_polygon(0.0, 0.0, 4.0, 4.0)), 3)?;
- let lines = linestring_column(vec![
- vec![(1.0, 1.0), (3.0, 3.0)],
- vec![(1.0, 1.0), (9.0, 1.0)],
- vec![(5.0, 5.0), (9.0, 9.0)],
- ])?;
- assert_contains(container, lines, [true, false, false])
- }
-
/// Column vs column pairs rows: each polygon row is tested against the point row at the
/// same position.
#[test]
@@ -410,6 +649,83 @@ mod tests {
Ok(())
}
+ /// A nullable polygon column: unit squares at `centers`, the rows where `nulls` is true
+ /// masked out, spelled as `Masked` over non-nullable storage.
+ fn nullable_squares(centers: &[(f64, f64)], nulls: &[bool]) -> VortexResult {
+ let squares = centers
+ .iter()
+ .map(|&(x, y)| {
+ vec![vec![
+ (x - 1.0, y - 1.0),
+ (x + 1.0, y - 1.0),
+ (x + 1.0, y + 1.0),
+ (x - 1.0, y + 1.0),
+ (x - 1.0, y - 1.0),
+ ]]
+ })
+ .collect();
+ let polygons = polygon_column(squares)?;
+
+ Ok(
+ MaskedArray::try_new(polygons, Validity::from_iter(nulls.iter().map(|n| !n)))?
+ .into_array(),
+ )
+ }
+
+ /// Nullable geometry operands conjoin their validity before computing containment.
+ #[test]
+ fn contains_nullable_geometries_conjoins_validity() -> VortexResult<()> {
+ let session = vortex_array::array_session();
+ let mut ctx = session.create_execution_ctx();
+
+ let centers = [(0.0, 0.0), (5.0, 5.0), (0.5, -0.2), (9.0, 9.0), (0.0, 1.0)];
+ let nulls = [false, true, false, false, true];
+ let polygons = nullable_squares(¢ers, &nulls)?;
+ let points = nullable_point_column(vec![
+ Some((0.0, 0.0)),
+ Some((5.0, 5.0)),
+ None,
+ Some((0.0, 0.0)),
+ Some((0.0, 1.0)),
+ ])?;
+
+ let actual = SpatialContains::try_new_array(polygons, points)?
+ .into_array()
+ .execute::(&mut ctx)?
+ .into_array();
+ let expected = BoolArray::from_iter([Some(true), None, None, Some(false), None]);
+
+ assert_arrays_eq!(actual, expected, &mut ctx);
+ Ok(())
+ }
+
+ /// Geometry types without a null-tolerant decode fall back to filtering valid rows.
+ #[test]
+ fn contains_unsupported_geometry_falls_back_to_filter() -> VortexResult<()> {
+ let session = vortex_array::array_session();
+ let mut ctx = session.create_execution_ctx();
+
+ let validity = Validity::from_iter([true, false, true, true]);
+ let lines = linestring_column(vec![
+ vec![(0.0, 0.0), (4.0, 4.0)],
+ vec![(0.0, 0.0), (1.0, 1.0)],
+ vec![(2.0, 2.0), (3.0, 3.0)],
+ vec![(0.0, 4.0), (4.0, 0.0)],
+ ])?;
+ let nullable_lines = MaskedArray::try_new(lines.clone(), validity.clone())?.into_array();
+ let point = geometry_constant(&Geometry::Point(Point::new(2.0, 2.0)), 4)?;
+
+ let expected = SpatialContains::try_new_array(lines, point.clone())?.into_array();
+ let expected = MaskedArray::try_new(expected, validity)?.into_array();
+ let actual = SpatialContains::try_new_array(nullable_lines, point)?
+ .into_array()
+ .execute::(&mut ctx)?
+ .into_array();
+
+ assert_arrays_eq!(actual, expected, &mut ctx);
+ Ok(())
+ }
+
/// A non-geometry operand dtype is rejected up front, before execution.
#[test]
fn non_geometry_operand_is_rejected() -> VortexResult<()> {
@@ -419,4 +735,166 @@ mod tests {
assert!(result.is_err());
Ok(())
}
+
+ // The prepared-vs-expanded agreement grid: every constant arrangement of a pairing must
+ // return exactly what the fully expanded columns return.
+
+ /// A point geometry.
+ fn point(x: f64, y: f64) -> Geometry {
+ Geometry::Point(Point::new(x, y))
+ }
+
+ /// A linestring geometry through `coords`.
+ fn line(coords: Vec<(f64, f64)>) -> Geometry {
+ Geometry::LineString(LineString::from(coords))
+ }
+
+ /// A multipoint geometry over `coords`.
+ fn multipoint(coords: Vec<(f64, f64)>) -> Geometry {
+ Geometry::MultiPoint(MultiPoint::from(coords))
+ }
+
+ /// A two-point line segment geometry, the `Line` container variant.
+ fn line_geometry(start: (f64, f64), end: (f64, f64)) -> Geometry {
+ Geometry::Line(Line::new(
+ Coord {
+ x: start.0,
+ y: start.1,
+ },
+ Coord { x: end.0, y: end.1 },
+ ))
+ }
+
+ /// A multilinestring geometry over one linestring per entry of `parts`.
+ fn multilinestring(parts: Vec>) -> Geometry {
+ Geometry::MultiLineString(MultiLineString::new(
+ parts.into_iter().map(LineString::from).collect(),
+ ))
+ }
+
+ /// A geometry collection wrapping `parts`.
+ fn collection(parts: Vec) -> Geometry {
+ Geometry::GeometryCollection(GeometryCollection::from(parts))
+ }
+
+ /// An axis-aligned rectangle geometry, the `Rect` container variant.
+ fn rect_geometry(x0: f64, y0: f64, x1: f64, y1: f64) -> Geometry {
+ Geometry::Rect(Rect::new(Coord { x: x0, y: y0 }, Coord { x: x1, y: y1 }))
+ }
+
+ /// A triangle geometry large enough to contain the small test polygons.
+ fn triangle_geometry() -> Geometry {
+ Geometry::Triangle(Triangle::new(
+ Coord { x: 0.0, y: 0.0 },
+ Coord { x: 8.0, y: 0.0 },
+ Coord { x: 0.0, y: 8.0 },
+ ))
+ }
+
+ /// A two-part multipolygon: `4x4` squares at the origin and at `(10, 10)`.
+ fn two_part_multipolygon() -> Geometry {
+ Geometry::MultiPolygon(MultiPolygon::new(vec![
+ rect_polygon(0.0, 0.0, 4.0, 4.0),
+ rect_polygon(10.0, 10.0, 14.0, 14.0),
+ ]))
+ }
+
+ /// Every container variant `contains_route` distinguishes, checked against plain
+ /// `a.contains(b)` in all four constant arrangements.
+ ///
+ /// Every case is a containment geo answers `true`, which the test asserts: a pairing that is
+ /// false regardless of route (a lower-dimensional container, say) also agrees regardless of
+ /// route, and pins nothing. A true case fails when the prepared substitution diverges from
+ /// geo: a table row whose relate phrasing disagrees with geo's dispatch on this input, or a
+ /// bounding-rect prescreen that wrongly rejects a contained row. It is **not** a version
+ /// tripwire: a geo release that reshuffles its dispatch stays green wherever relate and the
+ /// direct algorithm agree, which is why the workspace pins `geo` exactly.
+ ///
+ /// This is the table's own regression, and the one to extend when geo grows a geometry type:
+ /// `constant_operands_agree_with_columns` below goes through real arrays and so is the better
+ /// end-to-end check, but it only covers the container types it has cases for, and WKB decoding
+ /// limits which types those can be. The MultiPoint and Line containers route relate only for
+ /// contained types a MultiPoint or Line can rarely contain, so their true cases lean on
+ /// `GeometryCollection` membership and collinear `MultiLineString` parts respectively.
+ #[rstest]
+ #[case::point(point(1.0, 1.0), point(1.0, 1.0))]
+ #[case::line(line_geometry((0.0, 0.0), (4.0, 4.0)), point(2.0, 2.0))]
+ #[case::line_x_multilinestring(line_geometry((0.0, 0.0), (4.0, 4.0)), multilinestring(vec![vec![(1.0, 1.0), (2.0, 2.0)]]))]
+ #[case::linestring(line(vec![(0.0, 0.0), (4.0, 4.0)]), multipoint(vec![(1.0, 1.0), (2.0, 2.0)]))]
+ #[case::polygon(rect_polygon(0.0, 0.0, 8.0, 8.0).into(), rect_polygon(2.0, 2.0, 4.0, 4.0).into())]
+ #[case::multipoint(multipoint(vec![(0.0, 0.0), (2.0, 2.0), (4.0, 4.0)]), collection(vec![point(2.0, 2.0)]))]
+ #[case::multilinestring(multilinestring(vec![vec![(0.0, 0.0), (4.0, 4.0)]]), line(vec![(1.0, 1.0), (2.0, 2.0)]))]
+ #[case::multipolygon(two_part_multipolygon(), rect_polygon(1.0, 1.0, 3.0, 3.0).into())]
+ #[case::geometrycollection(collection(vec![rect_polygon(0.0, 0.0, 8.0, 8.0).into()]), rect_polygon(2.0, 2.0, 4.0, 4.0).into())]
+ #[case::rect(rect_geometry(0.0, 0.0, 8.0, 8.0), line(vec![(2.0, 2.0), (4.0, 4.0)]))]
+ #[case::triangle(triangle_geometry(), rect_polygon(1.0, 1.0, 2.0, 2.0).into())]
+ fn routes_agree_with_geo_for_every_container(#[case] a: Geometry, #[case] b: Geometry) {
+ let expected = a.contains(&b);
+ assert!(
+ expected,
+ "route cases must be containments geo answers true, or every route agrees vacuously",
+ );
+
+ let arrangements = [
+ (None, None),
+ (Some(PreparedOperand::new(&a)), None),
+ (None, Some(PreparedOperand::new(&b))),
+ (
+ Some(PreparedOperand::new(&a)),
+ Some(PreparedOperand::new(&b)),
+ ),
+ ];
+
+ for (index, (const_a, const_b)) in arrangements.into_iter().enumerate() {
+ let operands = ConstOperands {
+ a: const_a,
+ b: const_b,
+ };
+ assert_eq!(
+ contains_row_prepared(&operands, &a, &b),
+ expected,
+ "arrangement {index} disagrees with geo's own contains",
+ );
+ }
+ }
+
+ /// Constant arrangements agree with expanded columns across the routes the prepared kernel
+ /// distinguishes: forward relate (polygon, linestring and multipoint containers), reversed
+ /// relate (multipolygon containers), and the direct pairings (a point on either side,
+ /// multipoint over multipoint, polygon over multipoint), including boundary contact,
+ /// crossing, disjoint and empty cases.
+ #[rstest]
+ #[case::polygon_nested_polygon(rect_polygon(0.0, 0.0, 8.0, 8.0).into(), rect_polygon(2.0, 2.0, 4.0, 4.0).into())]
+ #[case::polygon_touching_from_inside(rect_polygon(0.0, 0.0, 8.0, 8.0).into(), rect_polygon(0.0, 2.0, 2.0, 4.0).into())]
+ #[case::polygon_overlapping_polygon(rect_polygon(0.0, 0.0, 4.0, 4.0).into(), rect_polygon(2.0, 2.0, 6.0, 6.0).into())]
+ #[case::polygon_disjoint_polygon(rect_polygon(0.0, 0.0, 4.0, 4.0).into(), rect_polygon(20.0, 20.0, 24.0, 24.0).into())]
+ #[case::polygon_x_point_inside(rect_polygon(0.0, 0.0, 4.0, 4.0).into(), point(2.0, 2.0))]
+ #[case::polygon_x_point_on_boundary(rect_polygon(0.0, 0.0, 4.0, 4.0).into(), point(0.0, 2.0))]
+ #[case::polygon_x_point_outside(rect_polygon(0.0, 0.0, 4.0, 4.0).into(), point(20.0, 20.0))]
+ #[case::polygon_x_nan_point(rect_polygon(0.0, 0.0, 4.0, 4.0).into(), point(f64::NAN, 2.0))]
+ #[case::polygon_x_linestring_inside(rect_polygon(0.0, 0.0, 4.0, 4.0).into(), line(vec![(1.0, 1.0), (2.0, 2.0)]))]
+ #[case::polygon_x_linestring_on_boundary(rect_polygon(0.0, 0.0, 4.0, 4.0).into(), line(vec![(0.0, 1.0), (0.0, 3.0)]))]
+ #[case::polygon_x_linestring_crossing(rect_polygon(0.0, 0.0, 4.0, 4.0).into(), line(vec![(-2.0, 2.0), (2.0, 2.0)]))]
+ #[case::polygon_x_empty_linestring(rect_polygon(0.0, 0.0, 4.0, 4.0).into(), line(vec![]))]
+ #[case::polygon_x_multipoint_inside(rect_polygon(0.0, 0.0, 4.0, 4.0).into(), multipoint(vec![(1.0, 1.0), (2.0, 2.0)]))]
+ #[case::polygon_x_multipoint_on_boundary(rect_polygon(0.0, 0.0, 4.0, 4.0).into(), multipoint(vec![(0.0, 1.0), (0.0, 3.0)]))]
+ #[case::linestring_x_multipoint_on_line(line(vec![(0.0, 0.0), (4.0, 4.0)]), multipoint(vec![(1.0, 1.0), (2.0, 2.0)]))]
+ #[case::multipoint_x_multipoint_subset(multipoint(vec![(0.0, 0.0), (2.0, 2.0), (4.0, 4.0)]), multipoint(vec![(2.0, 2.0)]))]
+ #[case::multipoint_x_linestring_between_points(multipoint(vec![(0.0, 0.0), (4.0, 4.0)]), line(vec![(1.0, 1.0), (2.0, 2.0)]))]
+ #[case::multipolygon_x_polygon_in_one_part(two_part_multipolygon(), rect_polygon(1.0, 1.0, 3.0, 3.0).into())]
+ #[case::multipolygon_x_polygon_straddling(two_part_multipolygon(), rect_polygon(3.0, 3.0, 11.0, 11.0).into())]
+ #[case::multipolygon_x_polygon_disjoint(two_part_multipolygon(), rect_polygon(20.0, 20.0, 24.0, 24.0).into())]
+ #[case::multipolygon_x_point_inside(two_part_multipolygon(), point(11.0, 11.0))]
+ #[case::point_x_point_equal(point(1.0, 1.0), point(1.0, 1.0))]
+ #[case::point_x_polygon(point(2.0, 2.0), rect_polygon(0.0, 0.0, 4.0, 4.0).into())]
+ fn constant_operands_agree_with_columns(
+ #[case] a: Geometry,
+ #[case] b: Geometry,
+ ) -> VortexResult<()> {
+ assert_prepared_agrees_with_columns(
+ SpatialContains::try_new_array,
+ geometry_constant(&a, 3)?,
+ geometry_constant(&b, 3)?,
+ )
+ }
}
diff --git a/vortex-spatial/src/scalar_fn/execute.rs b/vortex-spatial/src/scalar_fn/execute.rs
index 3a7494bcb39..2acdce76e36 100644
--- a/vortex-spatial/src/scalar_fn/execute.rs
+++ b/vortex-spatial/src/scalar_fn/execute.rs
@@ -7,17 +7,14 @@
//! propagation without prescribing how a kernel represents geometries or builds its output.
//! Native columnar kernels such as `ST_MakeLine` use these dispatchers directly.
//!
-//! [`execute_unary_geo_types`] and [`execute_binary_geo_types`] are convenience adapters for
-//! row-oriented algorithms from the `geo` ecosystem. They decode valid inputs into
-//! `geo_types::Geometry`; the final output is still a Vortex [`ArrayRef`], such as an `f64` or
-//! boolean array.
+//! [`execute_unary_geo_types`] adapts row-oriented algorithms from the `geo` ecosystem. It decodes
+//! valid inputs into `geo_types::Geometry`; the final output is still a Vortex [`ArrayRef`].
mod binary;
mod geo_types;
mod unary;
pub(crate) use binary::dispatch_binary;
-pub(crate) use binary::execute_binary_geo_types;
pub(crate) use unary::dispatch_unary;
pub(crate) use unary::execute_unary_geo_types;
use vortex_array::ArrayRef;
diff --git a/vortex-spatial/src/scalar_fn/execute/binary.rs b/vortex-spatial/src/scalar_fn/execute/binary.rs
index f2c03bd1beb..5cf639461b0 100644
--- a/vortex-spatial/src/scalar_fn/execute/binary.rs
+++ b/vortex-spatial/src/scalar_fn/execute/binary.rs
@@ -1,28 +1,20 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright the Vortex contributors
-//! Binary pairwise dispatch, plus an adapter for row-oriented `geo_types` kernels.
+//! Binary constant-and-column operand dispatch.
-use geo::BoundingRect;
-use geo_types::Geometry;
-use geo_types::Rect;
use vortex_array::ArrayRef;
use vortex_array::ExecutionCtx;
use vortex_array::IntoArray;
use vortex_array::arrays::Constant;
use vortex_array::arrays::ConstantArray;
use vortex_array::dtype::DType;
-use vortex_array::dtype::Nullability;
use vortex_array::scalar::Scalar;
use vortex_error::VortexResult;
use vortex_mask::Mask;
use super::Execution;
use super::Operand;
-use super::geo_types::GeoTypesOutput;
-use super::geo_types::eval_column;
-use super::geo_types::eval_column_pair;
-use crate::extension::single_geometry;
/// Dispatch a binary strict geometry kernel over constants and columns.
///
@@ -80,6 +72,7 @@ where
if len != 0 && valid.all_false() {
return Ok(ConstantArray::new(Scalar::null(output_dtype), len).into_array());
}
+
kernel(
Execution {
operands: [left, right],
@@ -90,245 +83,3 @@ where
ctx,
)
}
-
-/// A bounding-rectangle pre-check for [`execute_binary_geo_types`]'s one-constant paths.
-///
-/// Called per row with rectangles in operand order, it returns `Some(result)` when they prove the
-/// result and `None` when the exact kernel must run.
-pub(crate) type BboxPrecheck = fn(&Rect, &Rect) -> Option;
-
-/// Run a binary row-oriented kernel whose inputs are decoded to `geo_types::Geometry`.
-///
-/// The `geo_types` name describes the values passed to `compute`, not the output. `T` is converted
-/// into a Vortex array before this function returns. Nulls propagate from either operand. With
-/// exactly one constant operand, `bbox_precheck` may prove a result from the fixed constant
-/// bounding rectangle and the current row's rectangle before the exact kernel runs.
-pub(crate) fn execute_binary_geo_types(
- left: &ArrayRef,
- right: &ArrayRef,
- compute: F,
- bbox_precheck: Option>,
- ctx: &mut ExecutionCtx,
-) -> VortexResult
-where
- T: GeoTypesOutput,
- F: Fn(&Geometry, &Geometry) -> T + Copy,
-{
- let nullability = Nullability::from(left.dtype().is_nullable() || right.dtype().is_nullable());
- dispatch_binary(
- left,
- right,
- T::dtype(nullability),
- |execution, ctx| match execution.operands {
- [Operand::Constant(left), Operand::Constant(right)] => {
- let left = single_geometry(&left, ctx)?;
- let right = single_geometry(&right, ctx)?;
- Ok(ConstantArray::new(
- compute(&left, &right).into_scalar(execution.nullability),
- execution.len,
- )
- .into_array())
- }
- [Operand::Constant(left), Operand::Column(right)] => {
- let left = single_geometry(&left, ctx)?;
- let prescreen = bbox_precheck.zip(left.bounding_rect());
- eval_column(
- &right,
- &execution.valid,
- |right| {
- prescreen
- .and_then(|(precheck, fixed)| precheck(&fixed, &right.bounding_rect()?))
- .unwrap_or_else(|| compute(&left, right))
- },
- execution.nullability,
- ctx,
- )
- }
- [Operand::Column(left), Operand::Constant(right)] => {
- let right = single_geometry(&right, ctx)?;
- let prescreen = bbox_precheck.zip(right.bounding_rect());
- eval_column(
- &left,
- &execution.valid,
- |left| {
- prescreen
- .and_then(|(precheck, fixed)| precheck(&left.bounding_rect()?, &fixed))
- .unwrap_or_else(|| compute(left, &right))
- },
- execution.nullability,
- ctx,
- )
- }
- [Operand::Column(left), Operand::Column(right)] => eval_column_pair(
- &left,
- &right,
- &execution.valid,
- compute,
- execution.nullability,
- ctx,
- ),
- },
- ctx,
- )
-}
-
-#[cfg(test)]
-mod tests {
- use std::cell::Cell;
-
- use geo::Contains;
- use geo::Intersects;
- use geo_types::Geometry;
- use vortex_array::ArrayRef;
- use vortex_array::ExecutionCtx;
- use vortex_array::IntoArray;
- use vortex_array::VortexSessionExecute;
- use vortex_array::arrays::BoolArray;
- use vortex_array::arrays::ConstantArray;
- use vortex_array::assert_arrays_eq;
- use vortex_array::validity::Validity;
- use vortex_buffer::BitBuffer;
- use vortex_error::VortexResult;
-
- use super::BboxPrecheck;
- use super::execute_binary_geo_types;
- use crate::test_harness::linestring_column;
- use crate::test_harness::nullable_point_column;
- use crate::test_harness::point_column;
- use crate::test_harness::polygon_column;
-
- const DISJOINT_PRECHECK: BboxPrecheck =
- |left, right| (!left.intersects(right)).then_some(false);
-
- fn triangle_constant(len: usize, ctx: &mut ExecutionCtx) -> VortexResult {
- let ring = vec![(0.0, 0.0), (10.0, 0.0), (0.0, 10.0), (0.0, 0.0)];
- let scalar = polygon_column(vec![vec![ring]])?.execute_scalar(0, ctx)?;
- Ok(ConstantArray::new(scalar, len).into_array())
- }
-
- fn counting_intersects(
- counter: &Cell,
- ) -> impl Fn(&Geometry, &Geometry) -> bool + Copy {
- move |left, right| {
- counter.set(counter.get() + 1);
- left.intersects(right)
- }
- }
-
- #[test]
- fn bbox_precheck_skips_exact_test() -> VortexResult<()> {
- let session = vortex_array::array_session();
- let mut ctx = session.create_execution_ctx();
- let triangle = triangle_constant(3, &mut ctx)?;
- let probes = point_column(vec![50.0, 8.0, 2.0], vec![50.0, 8.0, 2.0])?;
- let exact_runs = Cell::new(0);
-
- let result = execute_binary_geo_types(
- &triangle,
- &probes,
- counting_intersects(&exact_runs),
- Some(DISJOINT_PRECHECK),
- &mut ctx,
- )?;
-
- assert_arrays_eq!(result, BoolArray::from_iter([false, false, true]), &mut ctx);
- assert_eq!(exact_runs.get(), 2);
- Ok(())
- }
-
- #[test]
- fn bbox_precheck_leaves_nulls_alone() -> VortexResult<()> {
- let session = vortex_array::array_session();
- let mut ctx = session.create_execution_ctx();
- let triangle = triangle_constant(3, &mut ctx)?;
- let probes = nullable_point_column(vec![Some((50.0, 50.0)), None, Some((2.0, 2.0))])?;
- let exact_runs = Cell::new(0);
-
- let result = execute_binary_geo_types(
- &triangle,
- &probes,
- counting_intersects(&exact_runs),
- Some(DISJOINT_PRECHECK),
- &mut ctx,
- )?;
- let expected = BoolArray::new(
- BitBuffer::from_iter([false, false, true]),
- Validity::from_iter([true, false, true]),
- )
- .into_array();
-
- assert_arrays_eq!(result, expected, &mut ctx);
- assert_eq!(exact_runs.get(), 1);
- Ok(())
- }
-
- #[test]
- fn bbox_precheck_sees_rects_in_operand_order() -> VortexResult<()> {
- let session = vortex_array::array_session();
- let mut ctx = session.create_execution_ctx();
- let probes = point_column(vec![2.0, 50.0], vec![2.0, 50.0])?;
- let triangle = triangle_constant(2, &mut ctx)?;
- let exact_runs = Cell::new(0);
- let counted = |left: &Geometry, right: &Geometry| {
- exact_runs.set(exact_runs.get() + 1);
- left.contains(right)
- };
-
- let result = execute_binary_geo_types(
- &probes,
- &triangle,
- counted,
- Some(|left, right| (!left.contains(right)).then_some(false)),
- &mut ctx,
- )?;
-
- assert_arrays_eq!(result, BoolArray::from_iter([false, false]), &mut ctx);
- assert_eq!(exact_runs.get(), 0);
- Ok(())
- }
-
- #[test]
- fn empty_constant_falls_through_to_exact() -> VortexResult<()> {
- let session = vortex_array::array_session();
- let mut ctx = session.create_execution_ctx();
- let scalar = linestring_column(vec![vec![]])?.execute_scalar(0, &mut ctx)?;
- let empty = ConstantArray::new(scalar, 2).into_array();
- let probes = point_column(vec![2.0, 50.0], vec![2.0, 50.0])?;
- let exact_runs = Cell::new(0);
-
- let result = execute_binary_geo_types(
- &empty,
- &probes,
- counting_intersects(&exact_runs),
- Some(DISJOINT_PRECHECK),
- &mut ctx,
- )?;
-
- assert_arrays_eq!(result, BoolArray::from_iter([false, false]), &mut ctx);
- assert_eq!(exact_runs.get(), 2);
- Ok(())
- }
-
- #[test]
- fn bbox_precheck_matches_exact_results() -> VortexResult<()> {
- let session = vortex_array::array_session();
- let mut ctx = session.create_execution_ctx();
- let triangle = triangle_constant(6, &mut ctx)?;
- let probes = nullable_point_column(vec![
- Some((50.0, 50.0)),
- Some((8.0, 8.0)),
- Some((2.0, 2.0)),
- None,
- Some((0.0, 0.0)),
- Some((10.0, 0.0)),
- ])?;
- let exact = |left: &Geometry, right: &Geometry| left.intersects(right);
-
- let with_precheck =
- execute_binary_geo_types(&triangle, &probes, exact, Some(DISJOINT_PRECHECK), &mut ctx)?;
- let exact_only = execute_binary_geo_types(&triangle, &probes, exact, None, &mut ctx)?;
-
- assert_arrays_eq!(with_precheck, exact_only, &mut ctx);
- Ok(())
- }
-}
diff --git a/vortex-spatial/src/scalar_fn/execute/geo_types.rs b/vortex-spatial/src/scalar_fn/execute/geo_types.rs
index 038aca46502..7007f02cfc6 100644
--- a/vortex-spatial/src/scalar_fn/execute/geo_types.rs
+++ b/vortex-spatial/src/scalar_fn/execute/geo_types.rs
@@ -118,27 +118,3 @@ where
let values = decoded.iter().map(compute).collect();
Ok(T::build_array(len, valid, values, nullability))
}
-
-/// Evaluate a decoded kernel over rows where both geometry columns are valid.
-pub(super) fn eval_column_pair(
- left: &ArrayRef,
- right: &ArrayRef,
- valid: &Mask,
- compute: F,
- nullability: Nullability,
- ctx: &mut ExecutionCtx,
-) -> VortexResult
-where
- T: GeoTypesOutput,
- F: Fn(&Geometry, &Geometry) -> T,
-{
- let len = left.len();
- let left = geometries(&left.filter(valid.clone())?, ctx)?;
- let right = geometries(&right.filter(valid.clone())?, ctx)?;
- let values = left
- .iter()
- .zip(&right)
- .map(|(left, right)| compute(left, right))
- .collect();
- Ok(T::build_array(len, valid, values, nullability))
-}
diff --git a/vortex-spatial/src/scalar_fn/intersects.rs b/vortex-spatial/src/scalar_fn/intersects.rs
index 77d33886ff3..9a3a198e838 100644
--- a/vortex-spatial/src/scalar_fn/intersects.rs
+++ b/vortex-spatial/src/scalar_fn/intersects.rs
@@ -3,44 +3,27 @@
//! `ST_Intersects`: OGC intersection test between two native geometries.
+use geo::BoundingRect;
use geo::Intersects;
+use geo_types::Geometry;
+use geo_types::Rect;
use vortex_array::ArrayRef;
-use vortex_array::ExecutionCtx;
use vortex_array::arrays::ScalarFnArray;
use vortex_array::dtype::DType;
-use vortex_array::dtype::Nullability;
-use vortex_array::expr::Expression;
-use vortex_array::expr::union_child_validities;
-use vortex_array::scalar_fn::Arity;
-use vortex_array::scalar_fn::ChildName;
use vortex_array::scalar_fn::EmptyOptions;
-use vortex_array::scalar_fn::ExecutionArgs;
use vortex_array::scalar_fn::ScalarFnId;
-use vortex_array::scalar_fn::ScalarFnVTable;
use vortex_array::scalar_fn::TypedScalarFnInstance;
+use vortex_array::scalar_fn::unstable::row::InitializedElement;
+use vortex_array::scalar_fn::unstable::row::RowFn;
+use vortex_array::scalar_fn::unstable::row::RowVisitor;
+use vortex_array::scalar_fn::unstable::row::UninitElementSink;
use vortex_error::VortexResult;
-use vortex_error::vortex_ensure;
use vortex_session::VortexSession;
use vortex_session::registry::CachedId;
-use crate::extension::is_native_geometry;
-use crate::scalar_fn::execute::execute_binary_geo_types;
-
-/// Validate the two native geometry operands accepted by `ST_Intersects`.
-fn validate_intersects_operands(dtypes: &[DType]) -> VortexResult<()> {
- vortex_ensure!(
- dtypes.len() == 2,
- "spatial: intersects requires exactly two geometry operands, got {}",
- dtypes.len()
- );
- for dtype in dtypes {
- vortex_ensure!(
- is_native_geometry(dtype),
- "spatial: intersects operand {dtype} is not a native geometry type"
- );
- }
- Ok(())
-}
+use crate::scalar_fn::row::GeometryRow;
+#[cfg(test)]
+use crate::scalar_fn::row::probe;
/// OGC `ST_Intersects` (not disjoint; boundary contact counts) between two native geometry
/// operands, each a column or a constant literal.
@@ -58,74 +41,100 @@ impl SpatialIntersects {
}
}
-impl ScalarFnVTable for SpatialIntersects {
+impl RowFn for SpatialIntersects {
type Options = EmptyOptions;
+ const ARG_NAMES: &'static [&'static str] = &["a", "b"];
+ const FALLIBLE: bool = true;
+
fn id(&self) -> ScalarFnId {
static ID: CachedId = CachedId::new("vortex.st.intersects");
*ID
}
- fn serialize(&self, _: &Self::Options) -> VortexResult>> {
+ fn serialize(&self, _options: &Self::Options) -> VortexResult >> {
Ok(Some(vec![]))
}
- fn deserialize(&self, _: &[u8], _: &VortexSession) -> VortexResult {
+ fn deserialize(
+ &self,
+ _metadata: &[u8],
+ _session: &VortexSession,
+ ) -> VortexResult {
Ok(EmptyOptions)
}
- fn arity(&self, _: &Self::Options) -> Arity {
- Arity::Exact(2)
- }
-
- fn child_name(&self, _: &Self::Options, child_idx: usize) -> ChildName {
- match child_idx {
- 0 => ChildName::from("a"),
- 1 => ChildName::from("b"),
- _ => unreachable!("intersects has exactly two children"),
- }
- }
-
- fn return_dtype(&self, _: &Self::Options, dtypes: &[DType]) -> VortexResult {
- validate_intersects_operands(dtypes)?;
- let nullability = Nullability::from(dtypes.iter().any(DType::is_nullable));
- Ok(DType::Bool(nullability))
- }
-
- fn execute(
+ fn dispatch>(
&self,
- _: &Self::Options,
- args: &dyn ExecutionArgs,
- ctx: &mut ExecutionCtx,
- ) -> VortexResult {
- let a = args.get(0)?;
- let b = args.get(1)?;
- // Disjoint bounding rects prove the geometries disjoint; rect contact (closed test)
- // falls through to the exact test.
- execute_binary_geo_types(
- &a,
- &b,
- |x, y| x.intersects(y),
- Some(|ra, rb| (!ra.intersects(rb)).then_some(false)),
- ctx,
+ _options: &Self::Options,
+ _args: &[DType],
+ visitor: V,
+ ) -> VortexResult {
+ visitor.visit_prepared_into::<(GeometryRow, GeometryRow), UninitElementSink, _, _>(
+ |(a, b)| {
+ #[cfg(test)]
+ probe::record(a.is_some(), b.is_some());
+ ConstBboxes::new(a, b)
+ },
+ |bboxes, (a, b), output| {
+ // SAFETY: `output` is the `UninitElementSink` row supplied for this callback.
+ unsafe { InitializedElement::write(output, intersects_row_prepared(bboxes, a, b)) }
+ },
)
}
+}
- fn validity(
- &self,
- _: &Self::Options,
- expression: &Expression,
- ) -> VortexResult> {
- union_child_validities(expression)
- }
+/// Per-batch state for the intersects row kernel: the bounding rect of each operand that is
+/// constant for the batch.
+///
+/// geo opens many intersects pairings with `has_disjoint_bboxes`, an early-out that folds
+/// [`bounding_rect`] over both operands. For a batch-constant operand that fold recomputes the
+/// same rect every row, so it is hoisted here and [`intersects_row_prepared`] replays the
+/// comparison with the hoisted value. `None` marks an operand that varies by row or has no
+/// bounding rect (an empty geometry); both mean no early-out, exactly as `has_disjoint_bboxes`
+/// treats a missing rect.
+///
+/// [`bounding_rect`]: BoundingRect::bounding_rect
+struct ConstBboxes {
+ /// The bounding rect of operand `a` when it is batch-constant.
+ a: Option>,
+
+ /// The bounding rect of operand `b` when it is batch-constant.
+ b: Option>,
+}
- fn is_strict(&self, _: &Self::Options) -> bool {
- true
+impl ConstBboxes {
+ fn new(a: Option<&Geometry>, b: Option<&Geometry>) -> Self {
+ Self {
+ a: a.and_then(BoundingRect::bounding_rect),
+ b: b.and_then(BoundingRect::bounding_rect),
+ }
}
+}
- fn is_fallible(&self, _: &Self::Options) -> bool {
- false
- }
+/// Computes one row of intersects, spending any bounding rect hoisted into `bboxes`.
+///
+/// Disjoint bounding rectangles conservatively prove that the geometries do not intersect. The
+/// fall-through delegates to the unchanged `a.intersects(b)`, which refolds both rects internally,
+/// so a batch where every row overlaps pays one extra `bounding_rect` fold over the row operand;
+/// the win concentrates where most rows are disjoint, the usual spatial-filter shape.
+fn intersects_row_prepared(bboxes: &ConstBboxes, a: &Geometry, b: &Geometry) -> bool {
+ let disjoint = match (bboxes.a, bboxes.b) {
+ (None, None) => false,
+ (Some(bbox_a), Some(bbox_b)) => !bbox_a.intersects(&bbox_b),
+ (Some(bbox_a), None) => b
+ .bounding_rect()
+ .is_some_and(|bbox_b| !bbox_a.intersects(&bbox_b)),
+ (None, Some(bbox_b)) => a
+ .bounding_rect()
+ .is_some_and(|bbox_a| !bbox_a.intersects(&bbox_b)),
+ };
+
+ if disjoint {
+ return false;
+ }
+
+ a.intersects(b)
}
#[cfg(test)]
@@ -133,7 +142,9 @@ mod tests {
use geo_types::Coord;
use geo_types::Geometry;
use geo_types::LineString;
+ use geo_types::MultiPoint;
use geo_types::MultiPolygon;
+ use geo_types::Point;
use geo_types::Polygon;
use rstest::rstest;
use vortex_array::ArrayRef;
@@ -158,8 +169,10 @@ mod tests {
use wkb::writer::WriteOptions;
use super::SpatialIntersects;
+ use crate::scalar_fn::row::probe::assert_prepared_agrees_with_columns;
use crate::test_harness::nullable_point_column;
use crate::test_harness::point_column;
+ use crate::test_harness::rect_column;
/// A rectangle polygon with corners `(x0, y0)` and `(x1, y1)`, no holes.
fn rect_polygon(x0: f64, y0: f64, x1: f64, y1: f64) -> Polygon {
@@ -441,4 +454,85 @@ mod tests {
assert!(result.is_err());
Ok(())
}
+
+ // The prepared-vs-expanded agreement grid: every constant arrangement of a pairing must
+ // return exactly what the fully expanded columns return.
+
+ /// A point geometry.
+ fn point(x: f64, y: f64) -> Geometry {
+ Geometry::Point(Point::new(x, y))
+ }
+
+ /// A linestring geometry through `coords`.
+ fn line(coords: Vec<(f64, f64)>) -> Geometry {
+ Geometry::LineString(LineString::from(coords))
+ }
+
+ /// A multipoint geometry over `coords`.
+ fn multipoint(coords: Vec<(f64, f64)>) -> Geometry {
+ Geometry::MultiPoint(MultiPoint::from(coords))
+ }
+
+ /// Constant arrangements agree with expanded columns across the pairing classes the prepared
+ /// kernel treats differently: bbox-prechecked pairs (polygon x polygon, linestring x
+ /// anything, multipolygon blankets), direct pairs (points), the excluded `MultiPoint` route,
+ /// and an empty geometry whose bounding rect does not exist.
+ #[rstest]
+ #[case::polygons_overlapping(rect_polygon(0.0, 0.0, 4.0, 4.0).into(), rect_polygon(2.0, 2.0, 6.0, 6.0).into())]
+ #[case::polygons_touching_edge(rect_polygon(0.0, 0.0, 4.0, 4.0).into(), rect_polygon(4.0, 0.0, 8.0, 4.0).into())]
+ #[case::polygons_touching_corner(rect_polygon(0.0, 0.0, 4.0, 4.0).into(), rect_polygon(4.0, 4.0, 8.0, 8.0).into())]
+ #[case::polygons_disjoint(rect_polygon(0.0, 0.0, 4.0, 4.0).into(), rect_polygon(20.0, 20.0, 24.0, 24.0).into())]
+ #[case::polygons_nested(rect_polygon(0.0, 0.0, 8.0, 8.0).into(), rect_polygon(2.0, 2.0, 4.0, 4.0).into())]
+ #[case::polygon_x_point_inside(donut(), point(2.0, 2.0))]
+ #[case::polygon_x_point_on_boundary(donut(), point(0.0, 5.0))]
+ #[case::polygon_x_point_in_hole(donut(), point(5.0, 5.0))]
+ #[case::point_outside_x_polygon(point(20.0, 20.0), donut())]
+ #[case::nan_point_x_polygon(point(f64::NAN, 2.0), donut())]
+ #[case::polygon_x_nan_point(donut(), point(f64::NAN, 2.0))]
+ #[case::points_equal(point(1.0, 1.0), point(1.0, 1.0))]
+ #[case::points_distinct(point(1.0, 1.0), point(2.0, 1.0))]
+ #[case::linestring_crossing_polygon(line(vec![(-2.0, -2.0), (2.0, 2.0)]), rect_polygon(0.0, 0.0, 4.0, 4.0).into())]
+ #[case::linestring_disjoint_polygon(line(vec![(-2.0, -2.0), (-6.0, -6.0)]), rect_polygon(0.0, 0.0, 4.0, 4.0).into())]
+ #[case::linestring_in_polygon_hole(line(vec![(4.5, 4.5), (5.5, 5.5)]), donut())]
+ #[case::linestrings_crossing(line(vec![(0.0, 0.0), (4.0, 4.0)]), line(vec![(0.0, 4.0), (4.0, 0.0)]))]
+ #[case::linestrings_disjoint(line(vec![(0.0, 0.0), (4.0, 4.0)]), line(vec![(10.0, 10.0), (14.0, 14.0)]))]
+ #[case::empty_linestring_x_polygon(line(vec![]), rect_polygon(0.0, 0.0, 4.0, 4.0).into())]
+ #[case::multipoint_straddling_polygon(multipoint(vec![(2.0, 2.0), (20.0, 20.0)]), rect_polygon(0.0, 0.0, 4.0, 4.0).into())]
+ #[case::multipoint_outside_polygon(multipoint(vec![(20.0, 20.0), (30.0, 30.0)]), rect_polygon(0.0, 0.0, 4.0, 4.0).into())]
+ #[case::multipolygon_disjoint_polygon(
+ Geometry::MultiPolygon(MultiPolygon::new(vec![
+ rect_polygon(0.0, 0.0, 2.0, 2.0),
+ rect_polygon(10.0, 10.0, 12.0, 12.0),
+ ])),
+ rect_polygon(20.0, 20.0, 24.0, 24.0).into()
+ )]
+ fn constant_operands_agree_with_columns(
+ #[case] a: Geometry,
+ #[case] b: Geometry,
+ ) -> VortexResult<()> {
+ assert_prepared_agrees_with_columns(
+ SpatialIntersects::try_new_array,
+ geometry_constant(&a, 3)?,
+ geometry_constant(&b, 3)?,
+ )
+ }
+
+ /// `Rect` has no WKB form, so its constant comes from a one-row rect column; its conservative
+ /// bbox early-out and exact fall-through must agree with the expanded form like the rest.
+ #[test]
+ fn rect_operand_agrees_with_columns() -> VortexResult<()> {
+ let session = vortex_array::array_session();
+ let mut ctx = session.create_execution_ctx();
+
+ let rect_scalar = rect_column(vec![(0.0, 0.0, 4.0, 4.0)])?.execute_scalar(0, &mut ctx)?;
+ let rect_constant = ConstantArray::new(rect_scalar, 3).into_array();
+ let polygon_constant =
+ geometry_constant(&Geometry::Polygon(rect_polygon(2.0, 2.0, 6.0, 6.0)), 3)?;
+
+ assert_prepared_agrees_with_columns(
+ SpatialIntersects::try_new_array,
+ rect_constant,
+ polygon_constant,
+ )
+ }
}
diff --git a/vortex-spatial/src/scalar_fn/row.rs b/vortex-spatial/src/scalar_fn/row.rs
index b7353e10c1c..750497e5f32 100644
--- a/vortex-spatial/src/scalar_fn/row.rs
+++ b/vortex-spatial/src/scalar_fn/row.rs
@@ -32,10 +32,10 @@ unsafe impl InputElement for GeometryRow {
type View<'a> = &'a [Geometry];
type Elem<'a> = &'a Geometry;
- // A geometry row is decoded from its coordinate storage, which behind a null row holds arbitrary
- // coordinates that need not describe a well-formed geometry.
+ // A geometry row is decoded from its coordinate storage, which behind a null row holds
+ // arbitrary coordinates that need not describe a well-formed geometry.
const DENSE_SAFE: bool = false;
- // Decoding builds a geometry from stored coordinates, and a malformed one in a *valid* row is a
+ // Decoding builds a geometry from stored coordinates, and a malformed one in a _valid_ row is a
// domain error rather than an infrastructural failure.
const DECODE_FALLIBLE: bool = true;
fn validate(dtype: &DType) -> VortexResult<()> {
@@ -92,3 +92,91 @@ unsafe impl InputElement for GeometryRow {
geometries_null_tolerant(&array, ctx)
}
}
+
+/// Test-only support for the prepared geo row kernels: a probe recording which operands a
+/// `prepare` step saw as batch-constant, and the shared prepared-vs-expanded agreement check
+/// built on it.
+#[cfg(test)]
+pub(crate) mod probe {
+ use std::cell::Cell;
+
+ use vortex_array::ArrayRef;
+ use vortex_array::Canonical;
+ use vortex_array::ExecutionCtx;
+ use vortex_array::IntoArray;
+ use vortex_array::VortexSessionExecute;
+ use vortex_array::arrays::MaskedArray;
+ use vortex_array::arrays::ScalarFnArray;
+ use vortex_array::assert_arrays_eq;
+ use vortex_array::validity::Validity;
+ use vortex_error::VortexResult;
+
+ thread_local! {
+ /// Which operands the last `prepare` saw as constant, as a bitmask (bit 0 for `a`, bit 1
+ /// for `b`). Thread-local rather than a process global so concurrent tests in one process
+ /// (plain `cargo test`) cannot race it; execution runs on the calling thread.
+ pub(crate) static SEEN_CONSTANTS: Cell = const { Cell::new(u8::MAX) };
+ }
+
+ /// Record which operands `prepare` saw as constant.
+ pub(crate) fn record(a_constant: bool, b_constant: bool) {
+ SEEN_CONSTANTS.set(u8::from(a_constant) | (u8::from(b_constant) << 1));
+ }
+
+ /// Execute `build(a, b)` and assert that `prepare` saw exactly `expect_seen` as its constant
+ /// operands, so the test knows which decode path the inputs took.
+ fn run_probed(
+ build: &impl Fn(ArrayRef, ArrayRef) -> VortexResult,
+ a: ArrayRef,
+ b: ArrayRef,
+ expect_seen: u8,
+ ctx: &mut ExecutionCtx,
+ ) -> VortexResult {
+ SEEN_CONSTANTS.set(u8::MAX);
+ let result = build(a, b)?
+ .into_array()
+ .execute::(ctx)?
+ .into_array();
+
+ assert_eq!(
+ SEEN_CONSTANTS.get(),
+ expect_seen,
+ "prepare saw the wrong constant operands",
+ );
+ Ok(result)
+ }
+
+ /// Assert that every constant-operand arrangement of `build(a, b)` returns exactly what the
+ /// fully expanded columns return, and that each arrangement's constness really reached
+ /// `prepare` (so the constants exercised the stride-0 path rather than a decoded column).
+ ///
+ /// Arrangements: `a` constant, `b` constant, and both constant with `a` masked. A plain
+ /// constant pair folds to a single-row execution before the row loop, so masking one side is
+ /// what drives the both-hoisted arm across rows; that run is compared against the same mask
+ /// over the expanded column.
+ pub(crate) fn assert_prepared_agrees_with_columns(
+ build: impl Fn(ArrayRef, ArrayRef) -> VortexResult,
+ const_a: ArrayRef,
+ const_b: ArrayRef,
+ ) -> VortexResult<()> {
+ let session = vortex_array::array_session();
+ let mut ctx = session.create_execution_ctx();
+ let col_a = const_a.clone().execute::(&mut ctx)?.into_array();
+ let col_b = const_b.clone().execute::(&mut ctx)?.into_array();
+
+ let baseline = run_probed(&build, col_a.clone(), col_b.clone(), 0b00, &mut ctx)?;
+ let a_hoisted = run_probed(&build, const_a.clone(), col_b.clone(), 0b01, &mut ctx)?;
+ let b_hoisted = run_probed(&build, col_a.clone(), const_b.clone(), 0b10, &mut ctx)?;
+ assert_arrays_eq!(a_hoisted, baseline, &mut ctx);
+ assert_arrays_eq!(b_hoisted, baseline, &mut ctx);
+
+ let validity = Validity::from_iter((0..col_a.len()).map(|row| row != 1));
+ let masked_const_a = MaskedArray::try_new(const_a, validity.clone())?.into_array();
+ let masked_col_a = MaskedArray::try_new(col_a, validity)?.into_array();
+ let both_hoisted = run_probed(&build, masked_const_a, const_b, 0b11, &mut ctx)?;
+ let masked_baseline = run_probed(&build, masked_col_a, col_b, 0b00, &mut ctx)?;
+ assert_arrays_eq!(both_hoisted, masked_baseline, &mut ctx);
+
+ Ok(())
+ }
+}