diff --git a/Cargo.lock b/Cargo.lock index dfadbb32d4f..50a0ccf054e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -967,6 +967,21 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit-vec" version = "0.9.1" @@ -5806,7 +5821,7 @@ version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6832254d731cb022d46927ce64403221b280b17140516cafa21e43ee4140d633" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", "bstr", "futures", "indexmap 2.14.0", @@ -6826,6 +6841,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec 0.8.0", + "bitflags 2.13.1", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "prost" version = "0.12.6" @@ -7047,6 +7081,12 @@ dependencies = [ "url", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-xml" version = "0.39.4" @@ -7268,6 +7308,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "rand_xoshiro" version = "0.6.0" @@ -7947,6 +7996,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -9375,6 +9436,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.9.0" @@ -10323,7 +10390,7 @@ dependencies = [ "arrow-schema 58.4.0", "async-stream", "async-trait", - "bit-vec", + "bit-vec 0.9.1", "flatbuffers", "futures", "insta", @@ -10611,6 +10678,7 @@ dependencies = [ "geoarrow", "geoarrow-cast", "mimalloc", + "proptest", "prost 0.14.4", "rstest", "vortex-array", @@ -10769,6 +10837,15 @@ dependencies = [ "zstd", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 3ae5f7bdbce..b5d831033bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -213,6 +213,7 @@ percent-encoding = "2.3.2" pin-project-lite = "0.2.15" primitive-types = { version = "0.14.0" } proc-macro2 = "1.0.95" +proptest = "1.11.0" prost = "0.14" prost-build = "0.14" prost-types = "0.14" diff --git a/vortex-spatial/Cargo.toml b/vortex-spatial/Cargo.toml index 3193fe9aac0..7dd3b3c147e 100644 --- a/vortex-spatial/Cargo.toml +++ b/vortex-spatial/Cargo.toml @@ -37,6 +37,7 @@ _test-harness = [] [dev-dependencies] divan = { workspace = true } mimalloc = { workspace = true } +proptest = { workspace = true } rstest = { workspace = true } vortex-array = { workspace = true, features = ["_test-harness"] } vortex-layout = { workspace = true } diff --git a/vortex-spatial/src/tests/differential/check.rs b/vortex-spatial/src/tests/differential/check.rs new file mode 100644 index 00000000000..268d7a8b3de --- /dev/null +++ b/vortex-spatial/src/tests/differential/check.rs @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Execution and comparison: run a scalar function over a generated case through a real +//! session, then assert the result column equals its `geo` oracle values. Comparison is +//! exact, with no epsilon: kernel and oracle run the same floating-point operations, so any +//! drift is a real behavior change and must fail. + +use std::ops::Range; + +use geo_types::Geometry; +use vortex_array::ArrayRef; +use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::assert_arrays_eq; +use vortex_error::VortexResult; + +use super::fixture::BinaryInput; +use super::fixture::ConstSide; +use super::fixture::GeometryColumn; +use crate::tests::SESSION; + +/// Check one unary scalar function over a (possibly sliced) column against its oracle. +pub(super) fn check_unary( + column: &GeometryColumn, + slice: Range, + build: impl Fn(ArrayRef) -> VortexResult, + oracle: impl Fn(&Geometry) -> f64, +) -> VortexResult<()> { + let mut ctx = SESSION.create_execution_ctx(); + let result = build(column.to_array()?.slice(slice.clone())?)?; + let expected = + PrimitiveArray::from_iter(column.rows[slice].iter().map(|row| oracle(&row.oracle()))) + .into_array(); + assert_arrays_eq!(result, expected, &mut ctx); + Ok(()) +} + +/// Check one binary scalar function against its oracle, exercising the column and constant +/// operand shapes. +pub(super) fn check_binary( + input: &BinaryInput, + build: impl Fn(ArrayRef, ArrayRef) -> VortexResult, + oracle: impl Fn(&Geometry, &Geometry) -> f64, +) -> VortexResult<()> { + let mut ctx = SESSION.create_execution_ctx(); + let len = input.a.rows.len(); + let (a_array, b_array) = (input.a.to_array()?, input.b.to_array()?); + + // Collapse one side to a constant of one of its rows, keeping the substituted row index + // for the expectation below. + let (a_array, b_array, a_const, b_const) = match input.constant { + ConstSide::Neither => (a_array, b_array, None, None), + ConstSide::Left(row) => { + let scalar = a_array.execute_scalar(row, &mut ctx)?; + let constant = ConstantArray::new(scalar, len).into_array(); + (constant, b_array, Some(row), None) + } + ConstSide::Right(row) => { + let scalar = b_array.execute_scalar(row, &mut ctx)?; + let constant = ConstantArray::new(scalar, len).into_array(); + (a_array, constant, None, Some(row)) + } + }; + let result = build(a_array, b_array)?; + + let (a_rows, b_rows) = (input.a.oracle_rows(), input.b.oracle_rows()); + let expected = PrimitiveArray::from_iter((0..len).map(|row| { + oracle( + &a_rows[a_const.unwrap_or(row)], + &b_rows[b_const.unwrap_or(row)], + ) + })) + .into_array(); + assert_arrays_eq!(result, expected, &mut ctx); + Ok(()) +} diff --git a/vortex-spatial/src/tests/differential/fixture.rs b/vortex-spatial/src/tests/differential/fixture.rs new file mode 100644 index 00000000000..77d661ca1eb --- /dev/null +++ b/vortex-spatial/src/tests/differential/fixture.rs @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! The data model of a generated case. +//! +//! A case is described by plain owned values that belong to neither Vortex nor `geo`, so one +//! generated value can be materialized on both sides of the comparison: +//! [`GeometryColumn::to_array`] builds the native column the kernel executes over, and +//! [`GeometryColumn::oracle_rows`] builds the per-row `geo` values the result must equal. + +use geo_types::Geometry; +use vortex_array::ArrayRef; +use vortex_error::VortexResult; + +use crate::test_harness::linestring_column; +use crate::test_harness::multilinestring_column; +use crate::test_harness::multipoint_column; +use crate::test_harness::multipolygon_column; +use crate::test_harness::point_column; +use crate::test_harness::polygon_column; +use crate::test_harness::rect_column; + +/// A row of `(x, y)` vertices: an open path or a polygon ring. +pub(super) type Vertices = Vec<(f64, f64)>; + +/// The native geometry families a generated column can hold. +#[derive(Debug, Clone, Copy)] +pub(super) enum Family { + Point, + LineString, + MultiPoint, + Polygon, + MultiLineString, + MultiPolygon, + Rect, +} + +/// Every family, for strategies that pick one at random. +pub(super) const FAMILIES: [Family; 7] = [ + Family::Point, + Family::LineString, + Family::MultiPoint, + Family::Polygon, + Family::MultiLineString, + Family::MultiPolygon, + Family::Rect, +]; + +/// One owned geometry. +#[derive(Debug, Clone)] +pub(super) enum Fixture { + Point(f64, f64), + LineString(Vertices), + MultiPoint(Vertices), + /// Exterior ring first, then holes. + Polygon(Vec), + MultiLineString(Vec), + MultiPolygon(Vec>), + /// Two corner points, `(x1, y1, x2, y2)`. + Rect(f64, f64, f64, f64), +} + +impl Fixture { + /// The `geo` oracle value, built through the same `geo_types` constructors the kernels' + /// decode path uses (notably `Polygon::new`, which closes rings), so the oracle sees + /// exactly the geometry the kernel sees. + pub(super) fn oracle(&self) -> Geometry { + match self { + Fixture::Point(x, y) => geo_types::Point::new(*x, *y).into(), + Fixture::LineString(line) => oracle_line(line).into(), + Fixture::MultiPoint(points) => geo_types::MultiPoint::from(points.clone()).into(), + Fixture::Polygon(rings) => oracle_polygon(rings).into(), + Fixture::MultiLineString(lines) => { + geo_types::MultiLineString::new(lines.iter().map(oracle_line).collect()).into() + } + Fixture::MultiPolygon(polygons) => { + geo_types::MultiPolygon::new(polygons.iter().map(|p| oracle_polygon(p)).collect()) + .into() + } + Fixture::Rect(x1, y1, x2, y2) => geo_types::Rect::new((*x1, *y1), (*x2, *y2)).into(), + } + } +} + +fn oracle_line(vertices: &Vertices) -> geo_types::LineString { + geo_types::LineString::from(vertices.clone()) +} + +fn oracle_polygon(rings: &[Vertices]) -> geo_types::Polygon { + let exterior = rings + .first() + .map(oracle_line) + .unwrap_or_else(|| geo_types::LineString::new(Vec::new())); + geo_types::Polygon::new(exterior, rings.iter().skip(1).map(oracle_line).collect()) +} + +/// A generated geometry column: one family, one geometry per row. +#[derive(Debug)] +pub(super) struct GeometryColumn { + pub(super) family: Family, + pub(super) rows: Vec, +} + +impl GeometryColumn { + /// Materialize as a native column. + pub(super) fn to_array(&self) -> VortexResult { + match self.family { + Family::Point => { + let (xs, ys) = self + .rows + .iter() + .map(|row| match row { + Fixture::Point(x, y) => (*x, *y), + _ => unreachable!("column rows share one family"), + }) + .unzip(); + point_column(xs, ys) + } + Family::LineString => linestring_column( + self.rows + .iter() + .map(|row| match row { + Fixture::LineString(line) => line.clone(), + _ => unreachable!("column rows share one family"), + }) + .collect(), + ), + Family::MultiPoint => multipoint_column( + self.rows + .iter() + .map(|row| match row { + Fixture::MultiPoint(points) => points.clone(), + _ => unreachable!("column rows share one family"), + }) + .collect(), + ), + Family::Polygon => polygon_column( + self.rows + .iter() + .map(|row| match row { + Fixture::Polygon(rings) => rings.clone(), + _ => unreachable!("column rows share one family"), + }) + .collect(), + ), + Family::MultiLineString => multilinestring_column( + self.rows + .iter() + .map(|row| match row { + Fixture::MultiLineString(lines) => lines.clone(), + _ => unreachable!("column rows share one family"), + }) + .collect(), + ), + Family::MultiPolygon => multipolygon_column( + self.rows + .iter() + .map(|row| match row { + Fixture::MultiPolygon(polygons) => polygons.clone(), + _ => unreachable!("column rows share one family"), + }) + .collect(), + ), + Family::Rect => rect_column( + self.rows + .iter() + .map(|row| match row { + Fixture::Rect(x1, y1, x2, y2) => (*x1, *y1, *x2, *y2), + _ => unreachable!("column rows share one family"), + }) + .collect(), + ), + } + } + + /// The oracle-side rows. + pub(super) fn oracle_rows(&self) -> Vec> { + self.rows.iter().map(Fixture::oracle).collect() + } +} + +/// Which binary operand, if any, is collapsed to a constant taken from one of its rows. +#[derive(Debug, Clone, Copy)] +pub(super) enum ConstSide { + Neither, + Left(usize), + Right(usize), +} + +/// A generated binary invocation: two equal-length columns, one side optionally constant. +#[derive(Debug)] +pub(super) struct BinaryInput { + pub(super) a: GeometryColumn, + pub(super) b: GeometryColumn, + pub(super) constant: ConstSide, +} diff --git a/vortex-spatial/src/tests/differential/generate.rs b/vortex-spatial/src/tests/differential/generate.rs new file mode 100644 index 00000000000..5a06da13516 --- /dev/null +++ b/vortex-spatial/src/tests/differential/generate.rs @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! The proptest strategies that generate cases. +//! +//! Coordinates are drawn from small lattices instead of the full float range, so generated +//! geometries often share vertices, touch boundaries, or have collinear edges. These are the +//! configurations where geometry bugs live, and uniform random floats would almost never +//! produce them. +//! +//! All geometries are well-formed: lines have at least two vertices and multi geometries at +//! least one member, because degenerate shapes panic inside the `geo` oracle. Null rows are +//! not generated either; null propagation is the executor's job, covered by each kernel's +//! unit tests. + +use std::ops::Range; + +use proptest::prelude::*; + +use super::fixture::BinaryInput; +use super::fixture::ConstSide; +use super::fixture::FAMILIES; +use super::fixture::Family; +use super::fixture::Fixture; +use super::fixture::GeometryColumn; +use super::fixture::Vertices; + +/// A finite ordinate, biased onto small integer and half-integer lattices so that generated +/// geometries frequently share vertices, touch boundaries, and have collinear edges. +fn ordinate() -> impl Strategy { + prop_oneof![ + 4 => (-4i32..=4).prop_map(f64::from), + 2 => (-8i32..=8).prop_map(|v| f64::from(v) / 2.0), + 1 => -100.0..100.0f64, + ] +} + +/// An open path of `min` to eight vertices. +fn path(min: usize) -> impl Strategy { + prop::collection::vec((ordinate(), ordinate()), min..=8) +} + +/// A ring of three to six vertices, mostly closed but occasionally left unclosed: the decode +/// path closes rings implicitly, and both forms must behave alike. +fn ring() -> impl Strategy { + ( + prop::collection::vec((ordinate(), ordinate()), 3..=6), + prop::bool::weighted(0.875), + ) + .prop_map(|(mut ring, close)| { + if close { + ring.push(ring[0]); + } + ring + }) +} + +/// One to three rings: an exterior plus up to two holes. +fn rings() -> impl Strategy> { + prop::collection::vec(ring(), 1..=3) +} + +/// One well-formed fixture of `family`. +fn fixture(family: Family) -> BoxedStrategy { + match family { + Family::Point => (ordinate(), ordinate()) + .prop_map(|(x, y)| Fixture::Point(x, y)) + .boxed(), + Family::LineString => path(2).prop_map(Fixture::LineString).boxed(), + Family::MultiPoint => path(1).prop_map(Fixture::MultiPoint).boxed(), + Family::Polygon => rings().prop_map(Fixture::Polygon).boxed(), + Family::MultiLineString => prop::collection::vec(path(2), 1..=3) + .prop_map(Fixture::MultiLineString) + .boxed(), + Family::MultiPolygon => prop::collection::vec(rings(), 1..=3) + .prop_map(Fixture::MultiPolygon) + .boxed(), + Family::Rect => (ordinate(), ordinate(), ordinate(), ordinate()) + .prop_map(|(x1, y1, x2, y2)| Fixture::Rect(x1, y1, x2, y2)) + .boxed(), + } +} + +/// A column of `len` rows of `family`. +fn column(family: Family, len: usize) -> impl Strategy { + prop::collection::vec(fixture(family), len..=len) + .prop_map(move |rows| GeometryColumn { family, rows }) +} + +/// A unary invocation over one fixed `family`: a column of up to 16 rows, plus a slice of it. +pub(super) fn unary_input(family: Family) -> impl Strategy)> { + (0..=16usize).prop_flat_map(move |len| { + ( + column(family, len), + (0..=len).prop_flat_map(move |start| (start..=len).prop_map(move |end| start..end)), + ) + }) +} + +/// A binary invocation: two equal-length columns, plus which side (if any) is constant. +/// +/// The operands share a family half the time ("coupled"), and each coupled row of `b` has a +/// one-in-four chance of being an exact clone of `a`'s row. This produces comparisons of a +/// geometry with itself, which independent generation would almost never create. +pub(super) fn binary_input() -> impl Strategy { + ( + prop::sample::select(FAMILIES.to_vec()), + prop::sample::select(FAMILIES.to_vec()), + 0..=12usize, + any::(), + ) + .prop_flat_map(|(fa, fb, len, coupled)| { + let fb = if coupled { fa } else { fb }; + let clone_mask = prop::collection::vec(prop::bool::weighted(0.25), len..=len); + let constant = if len == 0 { + Just(ConstSide::Neither).boxed() + } else { + prop_oneof![ + 2 => Just(ConstSide::Neither), + 1 => (0..len).prop_map(ConstSide::Left), + 1 => (0..len).prop_map(ConstSide::Right), + ] + .boxed() + }; + (column(fa, len), column(fb, len), clone_mask, constant).prop_map( + move |(a, mut b, clone_mask, constant)| { + if coupled { + for (row, clone) in clone_mask.into_iter().enumerate() { + if clone { + b.rows[row] = a.rows[row].clone(); + } + } + } + BinaryInput { a, b, constant } + }, + ) + }) +} diff --git a/vortex-spatial/src/tests/differential/mod.rs b/vortex-spatial/src/tests/differential/mod.rs new file mode 100644 index 00000000000..39dce8f048c --- /dev/null +++ b/vortex-spatial/src/tests/differential/mod.rs @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Differential harness for the spatial scalar functions. +//! +//! # The property +//! +//! Executing a scalar function over generated native geometry columns must equal the `geo` +//! crate applied row by row. +//! +//! The property names no implementation: it holds for today's `geo`-backed kernels and must +//! keep holding as native kernels replace them, so a failure that appears after a swap +//! implicates the new kernel alone. +//! +//! # Layout +//! +//! * [`fixture`]: the case data model, owned geometries materializable as both the native +//! column and the per-row oracle values. +//! * [`generate`]: the proptest strategies, biased toward structural edge cases. +//! * [`check`]: run the function through a real session and compare every row exactly. +//! +//! # When a case fails +//! +//! proptest shrinks the failure to a minimal counterexample and persists it to +//! `proptest-regressions/`, which every future run replays. + +mod check; +mod fixture; +mod generate; + +use geo::Distance; +use geo::Euclidean; +use geo::Length; +use geo_types::Geometry; +use proptest::prelude::*; +use vortex_array::IntoArray; + +use self::check::check_binary; +use self::check::check_unary; +use self::fixture::Family; +use self::generate::binary_input; +use self::generate::unary_input; +use crate::scalar_fn::distance::SpatialDistance; +use crate::scalar_fn::length::SpatialLength; + +proptest! { + /// `ST_Length` equals `geo`'s Euclidean length row by row. Line strings only, since the + /// kernel rejects other families at planning time. The kernel is already native, so the + /// two sides compute independently. + #[test] + fn st_length_matches_oracle((column, slice) in unary_input(Family::LineString)) { + check_unary( + &column, + slice, + |array| Ok(SpatialLength::try_new_array(array)?.into_array()), + |g| match g { + Geometry::LineString(line) => Euclidean.length(line), + _ => unreachable!("generated length inputs are line strings"), + }, + ) + .map_err(|e| TestCaseError::fail(e.to_string()))?; + } + + /// `ST_Distance` equals `geo`'s Euclidean distance row by row, over every family pair. + #[test] + fn st_distance_matches_oracle(input in binary_input()) { + check_binary( + &input, + |a, b| Ok(SpatialDistance::try_new_array(a, b)?.into_array()), + |a, b| Euclidean.distance(a, b), + ) + .map_err(|e| TestCaseError::fail(e.to_string()))?; + } +} diff --git a/vortex-spatial/src/tests/mod.rs b/vortex-spatial/src/tests/mod.rs index c83a30d476d..0866002546e 100644 --- a/vortex-spatial/src/tests/mod.rs +++ b/vortex-spatial/src/tests/mod.rs @@ -4,6 +4,7 @@ //! Arrow interop tests for the spatial extension types, exercising the session wiring set up //! by [`crate::initialize`]. +mod differential; mod linestring; mod multilinestring; mod multipoint;