diff --git a/benchmarks/duckdb-bench/src/lib.rs b/benchmarks/duckdb-bench/src/lib.rs index bf64f123956..4399d18a778 100644 --- a/benchmarks/duckdb-bench/src/lib.rs +++ b/benchmarks/duckdb-bench/src/lib.rs @@ -78,6 +78,11 @@ impl DuckClient { for stmt in &statements { self.connection().query(stmt)?; } + // After `LOAD spatial`, register `vortex_dwithin` so the radius filter pushes. No-op without it. + self.db + .as_ref() + .vortex_expect("DuckClient database accessed after close") + .register_geo_aliases()?; self.init_sql = statements; Ok(()) } @@ -127,6 +132,11 @@ impl DuckClient { .vortex_expect("connection just opened") .query(stmt)?; } + // Re-register `vortex_dwithin` against the fresh instance. + self.db + .as_ref() + .vortex_expect("database just opened") + .register_geo_aliases()?; Ok(()) } diff --git a/vortex-bench/src/spatialbench/benchmark.rs b/vortex-bench/src/spatialbench/benchmark.rs index 092c256621c..a1be1fedbb9 100644 --- a/vortex-bench/src/spatialbench/benchmark.rs +++ b/vortex-bench/src/spatialbench/benchmark.rs @@ -78,12 +78,12 @@ impl Benchmark for SpatialBenchBenchmark { .collect()) } - /// On the `vortex-native` lane, geometry columns surface as `GEOMETRY`, so drop the - /// `ST_GeomFromWKB(..)` wrappers and let DuckDB's `spatial` extension evaluate the `ST_*` - /// predicates directly on the native geometry. + /// Adapt a query to the storage format. The `vortex-native` lane surfaces geometry as `GEOMETRY`, + /// so it drops the `ST_GeomFromWKB(..)` wrappers and routes pushable `ST_DWithin` filters. fn query_for_format(&self, query: &str, format: Format) -> String { match format { - Format::VortexNative => strip_wkb_wrappers(query), + // Native geometry is `GEOMETRY`: drop `ST_GeomFromWKB(..)`, route pushable `ST_DWithin`. + Format::VortexNative => route_pushable_dwithin(&strip_wkb_wrappers(query)), _ => query.to_string(), } } @@ -234,3 +234,47 @@ fn strip_wkb_wrappers(sql: &str) -> String { out.push_str(rest); out } + +/// Rewrite `ST_DWithin(..)` calls with a geometry literal operand (`ST_GeomFromText`) to the +/// `vortex_dwithin` alias; leave the rest as `ST_DWithin`. `vortex_dwithin` is only correct when it +/// pushes (its bind is cleared), and only single-table filters against a literal push - a join (two +/// columns) does not, so it must keep `ST_DWithin`. +fn route_pushable_dwithin(sql: &str) -> String { + const OPEN: &str = "ST_DWithin("; + let mut out = String::with_capacity(sql.len()); + let mut rest = sql; + while let Some(pos) = rest.find(OPEN) { + out.push_str(&rest[..pos]); + let after = &rest[pos + OPEN.len()..]; + // Find this call's matching close paren, tracking nested parens (`ST_GeomFromText(..)`). + let mut depth = 1usize; + let mut end = None; + for (i, c) in after.char_indices() { + match c { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + end = Some(i); + break; + } + } + _ => {} + } + } + match end { + Some(close) if after[..close].contains("ST_GeomFromText") => { + out.push_str("vortex_dwithin("); + out.push_str(&after[..=close]); + rest = &after[close + 1..]; + } + // No literal operand (a join) or unbalanced: keep `ST_DWithin` for DuckDB to evaluate. + _ => { + out.push_str(OPEN); + rest = after; + } + } + } + out.push_str(rest); + out +} diff --git a/vortex-duckdb/cpp/expr.cpp b/vortex-duckdb/cpp/expr.cpp index 6470a9d338d..210aed50147 100644 --- a/vortex-duckdb/cpp/expr.cpp +++ b/vortex-duckdb/cpp/expr.cpp @@ -11,6 +11,15 @@ #include "duckdb/planner/expression/bound_operator_expression.hpp" #include "duckdb/planner/expression/bound_conjunction_expression.hpp" +#include "duckdb/catalog/catalog.hpp" +#include "duckdb/catalog/catalog_entry/scalar_function_catalog_entry.hpp" +#include "duckdb/main/capi/capi_internal.hpp" +#include "duckdb/main/client_context.hpp" +#include "duckdb/main/connection.hpp" +#include "duckdb/parser/parsed_data/create_scalar_function_info.hpp" + +#include + using namespace duckdb; extern "C" const char *duckdb_vx_sfunc_name(duckdb_vx_sfunc ffi_func) { @@ -21,6 +30,40 @@ extern "C" const char *duckdb_vx_sfunc_name(duckdb_vx_sfunc ffi_func) { return func->name.c_str(); } +extern "C" duckdb_state duckdb_vx_register_geo_aliases(duckdb_database ffi_db) { + if (!ffi_db) { + return DuckDBError; + } + const DatabaseWrapper &wrapper = *reinterpret_cast(ffi_db); + try { + Connection conn(*wrapper.database->instance); + ClientContext &context = *conn.context; + context.RunFunctionInTransaction([&]() { + auto &catalog = Catalog::GetSystemCatalog(context); + auto &entry = catalog.GetEntry( + context, DEFAULT_SCHEMA, "st_dwithin"); + // Copy each ST_DWithin overload to a non-throwing `vortex_dwithin` so DuckDB will push it. + ScalarFunctionSet set("vortex_dwithin"); + for (const auto &overload : entry.functions.functions) { + ScalarFunction copy = overload; + copy.name = "vortex_dwithin"; + copy.SetErrorMode(FunctionErrors::CANNOT_ERROR); + // Clear the bind so the radius stays as children[2] for the Vortex converter + // (ST_DWithin's bind folds it into bind_data). vortex_dwithin is only pushed, never run. + copy.bind = nullptr; + set.AddFunction(copy); + } + CreateScalarFunctionInfo info(std::move(set)); + info.on_conflict = OnCreateConflict::IGNORE_ON_CONFLICT; + catalog.CreateFunction(context, info); + }); + } catch (const std::exception &) { + // No `spatial` loaded, so there is no `ST_DWithin` to alias; nothing to register. + return DuckDBSuccess; + } + return DuckDBSuccess; +} + extern "C" const char *duckdb_vx_expr_to_string(duckdb_vx_expr ffi_expr) { if (!ffi_expr) { return nullptr; diff --git a/vortex-duckdb/cpp/include/expr.h b/vortex-duckdb/cpp/include/expr.h index 457a944e5d5..d0d76ffa3dc 100644 --- a/vortex-duckdb/cpp/include/expr.h +++ b/vortex-duckdb/cpp/include/expr.h @@ -13,6 +13,10 @@ typedef struct duckdb_vx_sfunc_ *duckdb_vx_sfunc; const char *duckdb_vx_sfunc_name(duckdb_vx_sfunc ffi_func); +/// Register `vortex_dwithin`, a non-throwing alias of the spatial extension's `ST_DWithin`, so the +/// radius filter pushes into the Vortex scan. +duckdb_state duckdb_vx_register_geo_aliases(duckdb_database ffi_db); + typedef struct duckdb_vx_expr_ *duckdb_vx_expr; /// Return the string representation of the expression. Must be freed with `duckdb_free`. diff --git a/vortex-duckdb/src/convert/expr.rs b/vortex-duckdb/src/convert/expr.rs index 324086e5775..42b5d7870cb 100644 --- a/vortex-duckdb/src/convert/expr.rs +++ b/vortex-duckdb/src/convert/expr.rs @@ -27,6 +27,7 @@ use vortex::expr::not; use vortex::expr::or_collect; use vortex::expr::root; use vortex::scalar::Scalar; +use vortex::scalar_fn::EmptyOptions; use vortex::scalar_fn::ScalarFnVTableExt; use vortex::scalar_fn::fns::between::Between; use vortex::scalar_fn::fns::between::BetweenOptions; @@ -36,6 +37,9 @@ use vortex::scalar_fn::fns::like::Like; use vortex::scalar_fn::fns::like::LikeOptions; use vortex::scalar_fn::fns::literal::Literal; use vortex::scalar_fn::fns::operators::Operator; +use vortex_geo::extension::WellKnownBinary; +use vortex_geo::extension::native_geometry_scalar_from_wkb; +use vortex_geo::scalar_fn::distance::GeoDistance; use crate::cpp::DUCKDB_VX_EXPR_TYPE; use crate::duckdb; @@ -57,6 +61,91 @@ fn from_bound_str(value: &duckdb::ExpressionRef) -> VortexResult { } } +/// Read an `f64` from a constant expression (e.g. the `ST_DWithin` distance literal). +fn from_bound_f64(value: &duckdb::ExpressionRef) -> VortexResult { + match value.as_class().vortex_expect("unknown class") { + BoundConstant(constant) => f64::try_from(&Scalar::try_from(constant.value)?), + _ => vortex_bail!("Expected f64 constant, got {:?}", value.as_class_id()), + } +} + +/// Lower a geo operand: a `GEOMETRY` literal arrives as WKB, decoded once to its native type so the +/// pushed `GeoDistance` stays native; a column ref recurses. `None` (unsupported type) skips push. +fn geo_operand( + value: &duckdb::ExpressionRef, + col_sub: Option<&Expression>, +) -> VortexResult> { + if let Some(BoundConstant(constant)) = value.as_class() { + let scalar = Scalar::try_from(constant.value)?; + let DType::Extension(ext_dtype) = scalar.dtype() else { + return Ok(None); + }; + if !ext_dtype.is::() { + return Ok(None); + } + let storage = scalar.as_extension().to_storage_scalar(); + let Some(buf) = storage.as_binary_opt().and_then(|b| b.value()) else { + return Ok(None); + }; + return Ok(native_geometry_scalar_from_wkb(buf.as_slice())?.map(lit)); + } + try_from_expression_inner(value, col_sub) +} + +/// Lower geo UDFs to native Vortex geo ops so the work runs in the scan. `None` otherwise. +fn try_from_geo_function( + name: &str, + func: &BoundFunction, + col_sub: Option<&Expression>, +) -> VortexResult> { + // Catch-all for every bound function: reject non-geo names before touching the children. + if !is_geo_function(name) { + debug!("bound function {name}"); + return Ok(None); + } + let children: Vec<_> = func.children().collect(); + let expr = match name.to_ascii_lowercase().as_str() { + "vortex_dwithin" => { + if children.len() != 3 { + return Ok(None); + } + let Some(a) = geo_operand(children[0], col_sub)? else { + return Ok(None); + }; + let Some(b) = geo_operand(children[1], col_sub)? else { + return Ok(None); + }; + let distance = from_bound_f64(children[2])?; + let geo_distance = GeoDistance.new_expr(EmptyOptions, [a, b]); + Binary.new_expr(Operator::Lte, [geo_distance, lit(distance)]) + } + "st_distance" => { + if children.len() != 2 { + return Ok(None); + } + let Some(a) = geo_operand(children[0], col_sub)? else { + return Ok(None); + }; + let Some(b) = geo_operand(children[1], col_sub)? else { + return Ok(None); + }; + GeoDistance.new_expr(EmptyOptions, [a, b]) + } + _ => return Ok(None), + }; + + Ok(Some(expr)) +} + +/// Geo UDFs that `try_from_geo_function` lowers - shared with `can_push_expression` so the pushable +/// and lowered sets can't drift. +fn is_geo_function(name: &str) -> bool { + matches!( + name.to_ascii_lowercase().as_str(), + "vortex_dwithin" | "st_distance" + ) +} + fn try_from_bound_function( func: &BoundFunction, col_sub: Option<&Expression>, @@ -115,10 +204,8 @@ fn try_from_bound_function( }; Like.new_expr(LikeOptions::default(), [value, lit(pattern)]) } - _ => { - debug!("bound function {}", func.scalar_function.name()); - return Ok(None); - } + // Geo UDFs are handled here. + name => return try_from_geo_function(name, func, col_sub), }; Ok(Some(expr)) @@ -173,6 +260,7 @@ pub fn can_push_expression(value: &duckdb::ExpressionRef) -> bool { || name == "~~" || name == "!~~" || name == "strlen" + || (is_geo_function(name) && func.children().all(can_push_expression)) } ExpressionClass::BoundOperator(op) => { if !matches!( diff --git a/vortex-duckdb/src/duckdb/database.rs b/vortex-duckdb/src/duckdb/database.rs index ab86503b291..e0b357c6b0e 100644 --- a/vortex-duckdb/src/duckdb/database.rs +++ b/vortex-duckdb/src/duckdb/database.rs @@ -90,4 +90,14 @@ impl DatabaseRef { ); Ok(()) } + + /// Register the non-throwing `vortex_dwithin` alias of `ST_DWithin` (via the C + /// `duckdb_vx_register_geo_aliases`) so the radius predicate pushes into the Vortex scan. + pub fn register_geo_aliases(&self) -> VortexResult<()> { + duckdb_try!( + unsafe { cpp::duckdb_vx_register_geo_aliases(self.as_ptr()) }, + "Failed to register geo aliases" + ); + Ok(()) + } } diff --git a/vortex-geo/src/extension/mod.rs b/vortex-geo/src/extension/mod.rs index 5cccc489297..3390c4e2e78 100644 --- a/vortex-geo/src/extension/mod.rs +++ b/vortex-geo/src/extension/mod.rs @@ -10,9 +10,21 @@ mod wkb; use std::fmt::Display; use std::sync::Arc; +use ::wkb::reader::GeometryType; +use arrow_array::BinaryArray; use geo_types::Geometry; +use geoarrow::array::GenericWkbArray; +use geoarrow::array::GeoArrowArray; +use geoarrow::datatypes::CoordType; use geoarrow::datatypes::Crs; +use geoarrow::datatypes::Dimension; +use geoarrow::datatypes::GeoArrowType; use geoarrow::datatypes::Metadata; +use geoarrow::datatypes::MultiPolygonType; +use geoarrow::datatypes::PointType; +use geoarrow::datatypes::PolygonType; +use geoarrow::datatypes::WkbType; +use geoarrow_cast::cast::cast; pub use multipolygon::*; pub use point::*; pub use polygon::*; @@ -22,6 +34,9 @@ use vortex_array::IntoArray; use vortex_array::arrays::ConstantArray; use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::extension::ExtensionArrayExt; +use vortex_array::arrow::FromArrowArray; +use vortex_array::dtype::extension::ExtDType; +use vortex_array::dtype::extension::ExtVTable; use vortex_array::scalar::Scalar; use vortex_error::VortexResult; use vortex_error::vortex_bail; @@ -67,6 +82,63 @@ pub(crate) fn single_geometry( .ok_or_else(|| vortex_err!("geo: constant operand decoded to no geometry")) } +/// Decode a WKB geometry literal (DuckDB's wire form for `GEOMETRY` constants) to its native +/// `Point`/`Polygon`/`MultiPolygon` scalar. `None` for unsupported types. Plan-time, one value only. +pub fn native_geometry_scalar_from_wkb(bytes: &[u8]) -> VortexResult> { + let metadata = geoarrow_metadata(&GeoMetadata::default()); + let binary = BinaryArray::from(vec![Some(bytes)]); + let wkb = GenericWkbArray::::try_from(( + &binary as &dyn arrow_array::Array, + WkbType::new(Arc::clone(&metadata)), + )) + .map_err(|e| vortex_err!("failed to read WKB literal: {e}"))?; + + // Cast the WKB value to `target`, import its native storage as a Vortex array. + let to_storage = |target: &GeoArrowType| -> VortexResult { + let native = + cast(&wkb, target).map_err(|e| vortex_err!("failed to cast WKB literal: {e}"))?; + ArrayRef::from_arrow(native.to_array_ref().as_ref(), false) + }; + + let scalar = match Wkb::try_from_bytes(bytes)?.geometry_type() { + GeometryType::Point => { + let target = GeoArrowType::Point( + PointType::new(Dimension::XY, metadata).with_coord_type(CoordType::Separated), + ); + geo_ext_scalar(Point, to_storage(&target)?)? + } + GeometryType::Polygon => { + let target = GeoArrowType::Polygon( + PolygonType::new(Dimension::XY, metadata).with_coord_type(CoordType::Separated), + ); + geo_ext_scalar(Polygon, to_storage(&target)?)? + } + GeometryType::MultiPolygon => { + let target = GeoArrowType::MultiPolygon( + MultiPolygonType::new(Dimension::XY, metadata) + .with_coord_type(CoordType::Separated), + ); + geo_ext_scalar(MultiPolygon, to_storage(&target)?)? + } + _ => return Ok(None), + }; + Ok(Some(scalar)) +} + +/// Wrap cast-from-WKB `storage` in its `vtable` extension type and pull out the single scalar. +// `scalar_at` is deprecated for `execute_scalar`, but there is no execution context at plan time. +#[allow(deprecated)] +fn geo_ext_scalar>( + vtable: V, + storage: ArrayRef, +) -> VortexResult { + let ext = ExtDType::try_with_vtable(vtable, GeoMetadata::default(), storage.dtype().clone())? + .erased(); + ExtensionArray::try_new(ext, storage)? + .into_array() + .scalar_at(0) +} + /// Extension metadata that is common to all the geospatial extension types. /// /// Currently, this is just the coordinate reference system (CRS). @@ -116,7 +188,13 @@ pub(crate) fn geo_metadata_from_arrow(metadata: &Metadata) -> GeoMetadata { #[cfg(test)] mod tests { use prost::Message; + use vortex_array::dtype::DType; + use vortex_error::VortexResult; + use vortex_error::vortex_err; + use super::Point; + use super::Polygon; + use super::native_geometry_scalar_from_wkb; use crate::extension::GeoMetadata; #[test] @@ -131,4 +209,42 @@ mod tests { let decoded = GeoMetadata::decode(bytes.as_slice()).unwrap(); assert_eq!(decoded, meta); } + + /// A little-endian WKB `POINT` literal decodes to the native `Point` extension scalar. + #[test] + fn decodes_wkb_point_to_native() -> VortexResult<()> { + let mut wkb = vec![1u8]; // little-endian byte order + wkb.extend_from_slice(&1u32.to_le_bytes()); // geometry type: point + wkb.extend_from_slice(&1.0f64.to_le_bytes()); // x + wkb.extend_from_slice(&2.0f64.to_le_bytes()); // y + + let scalar = native_geometry_scalar_from_wkb(&wkb)?.expect("a point scalar"); + let DType::Extension(ext) = scalar.dtype() else { + panic!("expected an extension dtype, got {}", scalar.dtype()); + }; + assert!(ext.is::()); + Ok(()) + } + + /// A little-endian WKB `POLYGON` literal decodes to the native `Polygon` extension scalar. + #[test] + fn decodes_wkb_polygon_to_native() -> VortexResult<()> { + let ring = [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (0.0, 0.0)]; + let mut wkb = vec![1u8]; // little-endian byte order + wkb.extend_from_slice(&3u32.to_le_bytes()); // geometry type: polygon + wkb.extend_from_slice(&1u32.to_le_bytes()); // one ring + let ring_len = u32::try_from(ring.len()).map_err(|e| vortex_err!("{e}"))?; + wkb.extend_from_slice(&ring_len.to_le_bytes()); + for (x, y) in ring { + wkb.extend_from_slice(&f64::to_le_bytes(x)); + wkb.extend_from_slice(&f64::to_le_bytes(y)); + } + + let scalar = native_geometry_scalar_from_wkb(&wkb)?.expect("a polygon scalar"); + let DType::Extension(ext) = scalar.dtype() else { + panic!("expected an extension dtype, got {}", scalar.dtype()); + }; + assert!(ext.is::()); + Ok(()) + } } diff --git a/vortex-geo/src/scalar_fn/distance.rs b/vortex-geo/src/scalar_fn/distance.rs index feb7ea833aa..4bbeff94062 100644 --- a/vortex-geo/src/scalar_fn/distance.rs +++ b/vortex-geo/src/scalar_fn/distance.rs @@ -10,8 +10,12 @@ use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::arrays::Constant; use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::ScalarFnArray; +use vortex_array::arrays::StructArray; +use vortex_array::arrays::extension::ExtensionArrayExt; +use vortex_array::arrays::struct_::StructArrayExt; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; use vortex_array::dtype::PType; @@ -27,6 +31,8 @@ use vortex_error::VortexResult; use vortex_error::vortex_ensure; use vortex_session::VortexSession; +use crate::extension::Point; +use crate::extension::coordinate::coordinate_from_struct; use crate::extension::geometries; use crate::extension::single_geometry; @@ -99,14 +105,25 @@ impl ScalarFnVTable for GeoDistance { (Some(query), None) => distances_to_constant(&b, query.scalar(), ctx), (None, Some(query)) => distances_to_constant(&a, query.scalar(), ctx), (None, None) => { - let ag = geometries(&a, ctx)?; - let bg = geometries(&b, ctx)?; vortex_ensure!( - ag.len() == bg.len(), + a.len() == b.len(), "geo distance: operand length mismatch {} vs {}", - ag.len(), - bg.len() + a.len(), + b.len() ); + // Fast path: two Point columns, distance straight over their `x`/`y` f64 buffers. + if is_nonnull_point(a.dtype()) && is_nonnull_point(b.dtype()) { + let (xa, ya) = point_xy(&a, ctx)?; + let (xb, yb) = point_xy(&b, ctx)?; + return Ok(point_distances( + xa.as_slice::().iter().copied(), + ya.as_slice::().iter().copied(), + xb.as_slice::().iter().copied(), + yb.as_slice::().iter().copied(), + )); + } + let ag = geometries(&a, ctx)?; + let bg = geometries(&b, ctx)?; let distances = ag.iter().zip(&bg).map(|(x, y)| Euclidean.distance(x, y)); Ok(PrimitiveArray::from_iter(distances).into_array()) } @@ -121,12 +138,73 @@ fn distances_to_constant( query: &Scalar, ctx: &mut ExecutionCtx, ) -> VortexResult { + // Fast path: Point column vs constant Point, `x`/`y` f64 buffers, broadcasting the constant. + if is_nonnull_point(operand.dtype()) && is_point(query.dtype()) { + let q = coordinate_from_struct(&query.as_extension().to_storage_scalar())?; + let (xs, ys) = point_xy(operand, ctx)?; + return Ok(point_distances( + xs.as_slice::().iter().copied(), + ys.as_slice::().iter().copied(), + std::iter::repeat(q.x), + std::iter::repeat(q.y), + )); + } + let query = single_geometry(query, ctx)?; let geoms = geometries(operand, ctx)?; let distances = geoms.iter().map(|g| Euclidean.distance(g, &query)); Ok(PrimitiveArray::from_iter(distances).into_array()) } +/// Extract the `x` and `y` `f64` columns from a native `Point` operand, for the columnar fast paths. +fn point_xy( + operand: &ArrayRef, + ctx: &mut ExecutionCtx, +) -> VortexResult<(PrimitiveArray, PrimitiveArray)> { + let storage = operand + .clone() + .execute::(ctx)? + .storage_array() + .clone() + .execute::(ctx)?; + let xs = storage + .unmasked_field_by_name("x")? + .clone() + .execute::(ctx)?; + let ys = storage + .unmasked_field_by_name("y")? + .clone() + .execute::(ctx)?; + Ok((xs, ys)) +} + +/// Per-row planar distance `sqrt(dx^2 + dy^2)` over two `(x, y)` f64 streams; a constant side is fed +/// as `repeat(c)`. +fn point_distances( + xa: impl Iterator, + ya: impl Iterator, + xb: impl Iterator, + yb: impl Iterator, +) -> ArrayRef { + let distances = xa.zip(ya).zip(xb.zip(yb)).map(|((xa, ya), (xb, yb))| { + let (dx, dy) = (xa - xb, ya - yb); + (dx * dx + dy * dy).sqrt() + }); + PrimitiveArray::from_iter(distances).into_array() +} + +/// Whether `dtype` is the native `Point` extension (eligible for the columnar fast path). +fn is_point(dtype: &DType) -> bool { + dtype + .as_extension_opt() + .is_some_and(|ext| ext.is::()) +} + +/// A non-nullable native `Point`, a column operand the fast path can read straight from `x`/`y`. +fn is_nonnull_point(dtype: &DType) -> bool { + is_point(dtype) && !dtype.is_nullable() +} + #[cfg(test)] mod tests { use vortex_array::ArrayRef;