This is an automated email from the ASF dual-hosted git repository.

andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git


The following commit(s) were added to refs/heads/main by this push:
     new 99237e16b2 feat: support native aggregate function `mode` (#4782)
99237e16b2 is described below

commit 99237e16b2de61183076045cb2ded1c11d20ec49
Author: Andy Grove <[email protected]>
AuthorDate: Thu Sep 10 13:33:13 2026 -0600

    feat: support native aggregate function `mode` (#4782)
    
    * feat: support native mode aggregate function
    
    Add native support for the Spark `mode` aggregate, the most frequent
    value within a group.
    
    Spark breaks ties on the default `mode(col)` form non-deterministically
    (the chosen value depends on JVM hash-map iteration order), so the
    function is registered as Incompatible and opt-in via allowIncompatible;
    Comet resolves ties deterministically by returning the smallest tied
    value. NULLs are ignored, empty input returns NULL, and float keys are
    normalized to match Spark. The deterministic-flag and WITHIN GROUP forms
    fall back to Spark.
    
    Closes #3970
    
    * fix: make mode's -0.0 key handling match the Spark version, plus review 
cleanups
    
    Addresses review feedback on #4782.
    
    The main item was the `-0.0` normalization in `normalize_key`. The review 
asked
    to stop collapsing `-0.0` into `0.0`, because Spark keys the frequency map 
on
    `java.lang.Double.equals` (via `OpenHashSet`), which distinguishes the two, 
and
    `NormalizeFloatingNumbers` never touches aggregate arguments. That is 
correct for
    Spark 3.4 through 4.1, but Spark 4.2.0 changed it: SPARK-57329 treats the 
split
    `-0.0`/`0.0` counts as a correctness bug and normalizes the key at update 
time.
    The fix landed in branch-4.2 after v4.2.0-rc1, so released 4.2.0 has it.
    
    Correct behaviour is therefore version-dependent, and neither 
always-collapsing
    nor never-collapsing is right across the profiles this repo builds. Added a
    `normalize_neg_zero` flag to the `Mode` proto message, set from 
`isSpark42Plus`
    in the serde (same pattern as `BloomFilterVersion` and `setIsSpark4Plus`), 
and
    gated the fold on it natively. `NaN` canonicalization stays unconditional, 
since
    `doubleToLongBits` collapses `NaN` on every supported version. 
`ScalarValue`'s
    `PartialEq`/`Hash` for floats are both bit-based, so once `NaN` is 
canonical the
    zeros stay distinct without further work.
    
    Test fixtures used `CAST(-0.0 AS DOUBLE)`, which does not produce a negative
    zero: an unsuffixed `-0.0` is a DecimalType literal and Decimal has no 
signed
    zero, so the column contained only `+0.0` and the existing signed-zero 
coverage
    was vacuous. Switched to `-0.0D`. Verified the new fixture is non-vacuous by
    forcing the flag to the wrong value and confirming it fails.
    
    Also in this commit:
    - correct the doc comment, which claimed the old behaviour matched
      `NormalizeFloatingNumbers`, and record which Spark comparison path governs
      `mode` versus `max_by`/`min_by` so the two are not "fixed" to match each 
other
    - drop the redundant `default_value` override
    - count key heap bytes in `size()` so string/binary/decimal modes do not
      under-report to the memory pool
    - assert the non-empty-groups invariant at both grouped emit sites
    - note that the `ScalarValue` frequency map is intentionally type-generic
    - add a `timestamp_ntz` compared query
    - add mode_within_group.sql pinning the Spark 4.x ordered forms as 
fallbacks,
      including that `ModeBuilder` rewrites `mode(col, false)` to the plain 
form so
      it still runs natively
    - TODO recording that the ASC WITHIN GROUP form could be Compatible
    
    * fix: account for group slot storage in mode's grouped accumulator size()
    
    ---------
    
    Co-authored-by: test <[email protected]>
---
 docs/source/user-guide/latest/expressions.md       |   2 +-
 native/core/src/execution/planner.rs               |   9 +-
 native/proto/src/proto/expr.proto                  |  10 +
 native/spark-expr/src/agg_funcs/mod.rs             |   2 +
 native/spark-expr/src/agg_funcs/mode.rs            | 675 +++++++++++++++++++++
 .../org/apache/comet/serde/QueryPlanSerde.scala    |   1 +
 .../scala/org/apache/comet/serde/aggregates.scala  |  78 ++-
 .../org/apache/spark/sql/comet/operators.scala     |  17 +-
 .../org/apache/comet/shims/CometTypeShim.scala     |   6 +
 .../org/apache/comet/shims/CometTypeShim.scala     |   6 +
 .../sql-tests/expressions/aggregate/mode.sql       | 253 ++++++++
 .../expressions/aggregate/mode_within_group.sql    |  70 +++
 12 files changed, 1122 insertions(+), 7 deletions(-)

diff --git a/docs/source/user-guide/latest/expressions.md 
b/docs/source/user-guide/latest/expressions.md
index 231d80affb..c29b995fe1 100644
--- a/docs/source/user-guide/latest/expressions.md
+++ b/docs/source/user-guide/latest/expressions.md
@@ -114,7 +114,7 @@ The tables below list every Spark built-in expression with 
its current status.
 | `median` | ✅ | — | Rewrites to `percentile(col, 0.5)` and runs natively for 
supported percentile inputs |
 | `min` | ✅ | Native |  |
 | `min_by` | 🔜 | — | 
[#3841](https://github.com/apache/datafusion-comet/issues/3841) |
-| `mode` | 🔜 | — | 
[#3970](https://github.com/apache/datafusion-comet/issues/3970) |
+| `mode` | ✅ | Native | `mode(col)` only; Spark breaks ties 
non-deterministically, so Comet returns the smallest tied value and falls back 
by default, opt-in via allowIncompatible 
([#3970](https://github.com/apache/datafusion-comet/issues/3970)) |
 | `percentile` | ✅ | Native | Single literal percentage on numeric input runs 
natively; array of percentages and a frequency argument fall back to Spark |
 | `percentile_cont` | ✅ | — | Spark 4.0+ `WITHIN GROUP (ORDER BY ...)`; 
ascending only runs natively, `DESC` falls back to Spark |
 | `percentile_disc` | 🔜 | — | Percentile aggregate |
diff --git a/native/core/src/execution/planner.rs 
b/native/core/src/execution/planner.rs
index 3b2080db57..37d5e74441 100644
--- a/native/core/src/execution/planner.rs
+++ b/native/core/src/execution/planner.rs
@@ -149,7 +149,7 @@ use datafusion_comet_proto::{
 use datafusion_comet_spark_expr::{
     jvm_udf::JvmScalarUdfExpr, ApproxPercentile, ArrayInsert, Avg, AvgDecimal, 
Cast, CheckOverflow,
     Correlation, Covariance, CreateNamedStruct, DecimalRescaleCheckOverflow, 
GetArrayStructFields,
-    GetStructField, HllPlusPlus, IfExpr, ListExtract, NormalizeNaNAndZero, 
Regr, RegrType,
+    GetStructField, HllPlusPlus, IfExpr, ListExtract, Mode, 
NormalizeNaNAndZero, Regr, RegrType,
     SparkCastOptions, Stddev, SumDecimal, ToJson, UnboundColumn, Variance, 
WideDecimalBinaryExpr,
     WideDecimalOp,
 };
@@ -3186,6 +3186,13 @@ impl PhysicalPlanner {
                 let func = 
AggregateUDF::new_from_impl(HllPlusPlus::new(expr.precision));
                 Self::create_aggr_func_expr("approx_count_distinct", schema, 
vec![child], func)
             }
+            AggExprStruct::Mode(expr) => {
+                let child = self.create_expr(expr.child.as_ref().unwrap(), 
Arc::clone(&schema))?;
+                let datatype = 
to_arrow_datatype(expr.datatype.as_ref().unwrap());
+                let func =
+                    AggregateUDF::new_from_impl(Mode::new(datatype, 
expr.normalize_neg_zero));
+                Self::create_aggr_func_expr("mode", schema, vec![child], func)
+            }
         }
     }
 
diff --git a/native/proto/src/proto/expr.proto 
b/native/proto/src/proto/expr.proto
index 10df7706eb..06fdf19925 100644
--- a/native/proto/src/proto/expr.proto
+++ b/native/proto/src/proto/expr.proto
@@ -163,6 +163,7 @@ message AggExpr {
     HllPlusPlus hllpp = 20;
     CollectList collectList = 21;
     Regr regr = 22;
+    Mode mode = 23;
   }
 
   // Optional filter expression for SQL FILTER (WHERE ...) clause.
@@ -352,6 +353,15 @@ message HllPlusPlus {
   int32 precision = 2;
 }
 
+message Mode {
+  Expr child = 1;
+  DataType datatype = 2;
+  // Whether `-0.0` should be folded into `0.0` before it is used as a 
frequency-map key.
+  // Spark only started doing this in 4.2.0 (SPARK-57329), so this tracks the 
Spark version
+  // Comet is running against. See `Mode` in the spark-expr crate for the full 
rationale.
+  bool normalize_neg_zero = 3;
+}
+
 enum EvalMode {
   LEGACY = 0;
   TRY = 1;
diff --git a/native/spark-expr/src/agg_funcs/mod.rs 
b/native/spark-expr/src/agg_funcs/mod.rs
index 828f3e989a..a63a8ddb0a 100644
--- a/native/spark-expr/src/agg_funcs/mod.rs
+++ b/native/spark-expr/src/agg_funcs/mod.rs
@@ -22,6 +22,7 @@ mod correlation;
 mod covariance;
 mod hll_plus_plus;
 mod hll_plus_plus_const;
+mod mode;
 mod percentile;
 mod quantile_summaries;
 mod regr;
@@ -37,6 +38,7 @@ pub use avg_decimal::AvgDecimal;
 pub use correlation::Correlation;
 pub use covariance::Covariance;
 pub use hll_plus_plus::{hllpp_precision, HllPlusPlus};
+pub use mode::Mode;
 pub use percentile::SparkPercentile;
 pub use regr::{Regr, RegrType};
 pub use stddev::Stddev;
diff --git a/native/spark-expr/src/agg_funcs/mode.rs 
b/native/spark-expr/src/agg_funcs/mode.rs
new file mode 100644
index 0000000000..7cba965329
--- /dev/null
+++ b/native/spark-expr/src/agg_funcs/mode.rs
@@ -0,0 +1,675 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use arrow::array::{Array, ArrayRef, AsArray, BooleanArray, StructArray};
+use arrow::datatypes::{DataType, Field, FieldRef, Fields, Int64Type};
+use datafusion::common::{internal_datafusion_err, not_impl_err, Result, 
ScalarValue};
+use datafusion::logical_expr::function::{AccumulatorArgs, StateFieldsArgs};
+use datafusion::logical_expr::{
+    Accumulator, AggregateUDFImpl, EmitTo, GroupsAccumulator, Signature, 
Volatility,
+};
+use datafusion::physical_expr::expressions::format_state_name;
+use std::cmp::Ordering;
+use std::collections::HashMap;
+use std::mem::size_of;
+use std::sync::Arc;
+
+/// Spark's `mode` aggregate: returns the most frequent value within a group, 
ignoring NULLs.
+///
+/// Spark breaks ties on the default `mode(col)` form non-deterministically 
(the value is chosen
+/// by JVM `OpenHashMap` iteration order), which a native hash map cannot 
reproduce bit-for-bit.
+/// Comet resolves ties deterministically by returning the smallest value, so 
this function is
+/// registered as `Incompatible` on the Scala side and is opt-in via 
`allowIncompatible`.
+///
+/// # Float keys
+///
+/// Spark keys the frequency map on the boxed input value and compares keys 
with
+/// `OpenHashSet`'s `_data(pos) equals k` 
(`core/.../util/collection/OpenHashSet.scala:122`), i.e.
+/// `java.lang.Double.equals`, which is defined via `doubleToLongBits`. That 
collapses every `NaN`
+/// bit pattern to one key but keeps `-0.0` and `0.0` apart. Note that
+/// `NormalizeFloatingNumbers` does *not* apply here: its `apply` only 
rewrites `WINDOW` and
+/// `JOIN` patterns, so an aggregate's argument reaches `Mode` un-normalized.
+///
+/// Spark 4.2.0 changed this. SPARK-57329 ("mode() returns incorrect result 
when input contains
+/// both -0.0 and 0.0") treats the split `-0.0`/`0.0` counts as a bug and 
normalizes the key at
+/// update time, so from 4.2.0 on the two fold into a single key. 
`normalize_neg_zero` therefore
+/// tracks the Spark version Comet is running against: it is `false` for Spark 
3.4 through 4.1 and
+/// `true` for 4.2.0+. `NaN` canonicalization is unconditional because every 
supported version
+/// collapses `NaN` via `doubleToLongBits`.
+///
+/// Do not "simplify" this to always normalize: `max_by`/`min_by` need the 
opposite treatment,
+/// because they compare the ordering column with 
`SQLOrderingUtil.compareDoubles`, which ties
+/// `-0.0 == 0.0` on every version.
+///
+/// Spark's `Mode` is a `TypedImperativeAggregate` with a single 
aggregation-buffer attribute, so
+/// the intermediate state is a single struct field `{ values: list<T>, 
counts: list<i64> }` (a
+/// parallel-array encoding of the frequency map) to keep the partial/final 
buffer schemas aligned
+/// with Spark.
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct Mode {
+    name: String,
+    signature: Signature,
+    data_type: DataType,
+    /// Whether `-0.0` folds into `0.0` before being used as a key (Spark 
4.2.0+; SPARK-57329).
+    normalize_neg_zero: bool,
+}
+
+impl Mode {
+    pub fn new(data_type: DataType, normalize_neg_zero: bool) -> Self {
+        Self {
+            name: "mode".to_string(),
+            signature: Signature::any(1, Volatility::Immutable),
+            data_type,
+            normalize_neg_zero,
+        }
+    }
+}
+
+/// Fields of the single struct state column `{values: list<T>, counts: 
list<i64>}`.
+fn state_struct_fields(data_type: &DataType) -> Fields {
+    let values_list = 
DataType::List(Arc::new(Field::new_list_field(data_type.clone(), true)));
+    let counts_list = 
DataType::List(Arc::new(Field::new_list_field(DataType::Int64, true)));
+    Fields::from(vec![
+        Field::new("values", values_list, false),
+        Field::new("counts", counts_list, false),
+    ])
+}
+
+/// Build the single-column struct state array holding one `{values, counts}` 
row per map.
+fn build_state(data_type: &DataType, maps: &[&HashMap<ScalarValue, i64>]) -> 
Result<StructArray> {
+    let mut value_lists = Vec::with_capacity(maps.len());
+    let mut count_lists = Vec::with_capacity(maps.len());
+    for map in maps {
+        let mut values = Vec::with_capacity(map.len());
+        let mut counts = Vec::with_capacity(map.len());
+        for (value, &count) in map.iter() {
+            values.push(value.clone());
+            counts.push(ScalarValue::Int64(Some(count)));
+        }
+        value_lists.push(ScalarValue::List(ScalarValue::new_list(
+            &values, data_type, true,
+        )));
+        count_lists.push(ScalarValue::List(ScalarValue::new_list(
+            &counts,
+            &DataType::Int64,
+            true,
+        )));
+    }
+    let values = ScalarValue::iter_to_array(value_lists)?;
+    let counts = ScalarValue::iter_to_array(count_lists)?;
+    Ok(StructArray::new(
+        state_struct_fields(data_type),
+        vec![values, counts],
+        None,
+    ))
+}
+
+impl AggregateUDFImpl for Mode {
+    fn name(&self) -> &str {
+        &self.name
+    }
+
+    fn signature(&self) -> &Signature {
+        &self.signature
+    }
+
+    fn return_type(&self, _arg_types: &[DataType]) -> Result<DataType> {
+        Ok(self.data_type.clone())
+    }
+
+    fn accumulator(&self, _acc_args: AccumulatorArgs) -> Result<Box<dyn 
Accumulator>> {
+        Ok(Box::new(ModeAccumulator::new(
+            self.data_type.clone(),
+            self.normalize_neg_zero,
+        )))
+    }
+
+    fn state_fields(&self, _args: StateFieldsArgs) -> Result<Vec<FieldRef>> {
+        Ok(vec![Arc::new(Field::new(
+            format_state_name(&self.name, "freq"),
+            DataType::Struct(state_struct_fields(&self.data_type)),
+            false,
+        ))])
+    }
+
+    fn groups_accumulator_supported(&self, _args: AccumulatorArgs) -> bool {
+        true
+    }
+
+    fn create_groups_accumulator(
+        &self,
+        _args: AccumulatorArgs,
+    ) -> Result<Box<dyn GroupsAccumulator>> {
+        Ok(Box::new(ModeGroupsAccumulator::new(
+            self.data_type.clone(),
+            self.normalize_neg_zero,
+        )))
+    }
+}
+
+/// Canonicalize a float key so that map lookups reproduce Spark's key 
equality.
+///
+/// `ScalarValue`'s `PartialEq`/`Hash` for `Float32`/`Float64` are both 
defined on `to_bits()`, so
+/// distinct `NaN` bit patterns would otherwise be distinct keys and `-0.0` is 
naturally kept apart
+/// from `0.0`. Collapsing `NaN` to one canonical value therefore reproduces 
`doubleToLongBits`
+/// equality, which is what Spark's `OpenHashSet` uses. `-0.0` is folded into 
`0.0` only when
+/// `normalize_neg_zero` is set, i.e. only on Spark 4.2.0+ (SPARK-57329); see 
[`Mode`].
+fn normalize_key(value: ScalarValue, normalize_neg_zero: bool) -> ScalarValue {
+    macro_rules! normalize_float {
+        ($variant:path, $f:expr, $nan:expr) => {
+            if $f.is_nan() {
+                $variant(Some($nan))
+            } else if normalize_neg_zero && $f == 0.0 {
+                // `-0.0 == 0.0` in IEEE 754, so this catches negative zero 
only.
+                $variant(Some(0.0))
+            } else {
+                $variant(Some($f))
+            }
+        };
+    }
+    match value {
+        ScalarValue::Float32(Some(f)) => 
normalize_float!(ScalarValue::Float32, f, f32::NAN),
+        ScalarValue::Float64(Some(f)) => 
normalize_float!(ScalarValue::Float64, f, f64::NAN),
+        other => other,
+    }
+}
+
+/// Add each non-null value in `array` to `map`, canonicalizing float keys.
+///
+/// The map is intentionally keyed on the type-generic `ScalarValue` rather 
than a monomorphized
+/// `HashMap<Hashable<T::Native>, _>`: `mode` supports every primitive type 
plus decimal, string and
+/// the temporal types, so one generic map is simpler than a kernel per type. 
Revisit if the hot
+/// primitive paths ever show up in a profile.
+fn count_values(
+    map: &mut HashMap<ScalarValue, i64>,
+    array: &ArrayRef,
+    idx: usize,
+    normalize_neg_zero: bool,
+) -> Result<()> {
+    if array.is_null(idx) {
+        return Ok(());
+    }
+    let key = normalize_key(ScalarValue::try_from_array(array, idx)?, 
normalize_neg_zero);
+    *map.entry(key).or_insert(0) += 1;
+    Ok(())
+}
+
+/// Fold row `row` of the struct-state columns (`{values, counts}`) into `map`.
+fn merge_state_row(
+    map: &mut HashMap<ScalarValue, i64>,
+    values_list: &arrow::array::ListArray,
+    counts_list: &arrow::array::ListArray,
+    row: usize,
+    normalize_neg_zero: bool,
+) -> Result<()> {
+    if values_list.is_null(row) {
+        return Ok(());
+    }
+    let values = values_list.value(row);
+    let counts = counts_list.value(row);
+    let counts = counts
+        .as_primitive_opt::<Int64Type>()
+        .ok_or_else(|| internal_datafusion_err!("mode state counts must be 
Int64"))?;
+    for i in 0..values.len() {
+        if values.is_null(i) {
+            continue;
+        }
+        let key = normalize_key(ScalarValue::try_from_array(&values, i)?, 
normalize_neg_zero);
+        *map.entry(key).or_insert(0) += counts.value(i);
+    }
+    Ok(())
+}
+
+/// Pick the mode from a frequency map: the value with the highest count, 
breaking ties by the
+/// smallest value. Returns a null scalar of `data_type` when the map is empty.
+fn eval_mode(counts: &HashMap<ScalarValue, i64>, data_type: &DataType) -> 
Result<ScalarValue> {
+    let mut best: Option<(&ScalarValue, i64)> = None;
+    for (value, &count) in counts.iter() {
+        let wins = match best {
+            None => true,
+            Some((best_value, best_count)) => {
+                count > best_count
+                    || (count == best_count
+                        && value.partial_cmp(best_value) == 
Some(Ordering::Less))
+            }
+        };
+        if wins {
+            best = Some((value, count));
+        }
+    }
+    match best {
+        Some((value, _)) => Ok(value.clone()),
+        None => ScalarValue::try_from(data_type),
+    }
+}
+
+/// Heap bytes held by the frequency map's keys, on top of the map's own slot 
allocation.
+///
+/// `HashMap::capacity` only accounts for the inline `(ScalarValue, i64)` 
slots, which misses the
+/// `String`/`Vec<u8>`/boxed-decimal payloads behind variable-length keys. 
Under-reporting those
+/// would hide real memory from the pool that drives spill decisions.
+fn map_size(map: &HashMap<ScalarValue, i64>) -> usize {
+    map.capacity() * size_of::<(ScalarValue, i64)>()
+        + map
+            .keys()
+            .map(|k| k.size().saturating_sub(size_of::<ScalarValue>()))
+            .sum::<usize>()
+}
+
+/// Non-grouped accumulator backing global `mode` aggregation.
+#[derive(Debug)]
+pub struct ModeAccumulator {
+    counts: HashMap<ScalarValue, i64>,
+    data_type: DataType,
+    normalize_neg_zero: bool,
+}
+
+impl ModeAccumulator {
+    fn new(data_type: DataType, normalize_neg_zero: bool) -> Self {
+        Self {
+            counts: HashMap::new(),
+            data_type,
+            normalize_neg_zero,
+        }
+    }
+}
+
+impl Accumulator for ModeAccumulator {
+    fn update_batch(&mut self, values: &[ArrayRef]) -> Result<()> {
+        let array = &values[0];
+        for i in 0..array.len() {
+            count_values(&mut self.counts, array, i, self.normalize_neg_zero)?;
+        }
+        Ok(())
+    }
+
+    fn merge_batch(&mut self, states: &[ArrayRef]) -> Result<()> {
+        let structs = states[0].as_struct();
+        let values_list = structs.column(0).as_list::<i32>();
+        let counts_list = structs.column(1).as_list::<i32>();
+        for row in 0..structs.len() {
+            merge_state_row(
+                &mut self.counts,
+                values_list,
+                counts_list,
+                row,
+                self.normalize_neg_zero,
+            )?;
+        }
+        Ok(())
+    }
+
+    fn state(&mut self) -> Result<Vec<ScalarValue>> {
+        let array = build_state(&self.data_type, &[&self.counts])?;
+        Ok(vec![ScalarValue::Struct(Arc::new(array))])
+    }
+
+    fn evaluate(&mut self) -> Result<ScalarValue> {
+        eval_mode(&self.counts, &self.data_type)
+    }
+
+    fn size(&self) -> usize {
+        size_of_val(self) + map_size(&self.counts)
+    }
+}
+
+/// Vectorized grouped accumulator: one frequency map per group.
+#[derive(Debug)]
+pub struct ModeGroupsAccumulator {
+    groups: Vec<HashMap<ScalarValue, i64>>,
+    data_type: DataType,
+    normalize_neg_zero: bool,
+}
+
+impl ModeGroupsAccumulator {
+    fn new(data_type: DataType, normalize_neg_zero: bool) -> Self {
+        Self {
+            groups: Vec::new(),
+            data_type,
+            normalize_neg_zero,
+        }
+    }
+
+    fn resize(&mut self, total_num_groups: usize) {
+        if self.groups.len() < total_num_groups {
+            self.groups.resize_with(total_num_groups, HashMap::new);
+        }
+    }
+}
+
+impl GroupsAccumulator for ModeGroupsAccumulator {
+    fn update_batch(
+        &mut self,
+        values: &[ArrayRef],
+        group_indices: &[usize],
+        opt_filter: Option<&BooleanArray>,
+        total_num_groups: usize,
+    ) -> Result<()> {
+        self.resize(total_num_groups);
+        let array = &values[0];
+        for (idx, &group_index) in group_indices.iter().enumerate() {
+            if let Some(f) = opt_filter {
+                if !f.is_valid(idx) || !f.value(idx) {
+                    continue;
+                }
+            }
+            count_values(
+                &mut self.groups[group_index],
+                array,
+                idx,
+                self.normalize_neg_zero,
+            )?;
+        }
+        Ok(())
+    }
+
+    fn merge_batch(
+        &mut self,
+        values: &[ArrayRef],
+        group_indices: &[usize],
+        total_num_groups: usize,
+    ) -> Result<()> {
+        self.resize(total_num_groups);
+        let structs = values[0].as_struct();
+        let values_list = structs.column(0).as_list::<i32>();
+        let counts_list = structs.column(1).as_list::<i32>();
+        for (row, &group_index) in group_indices.iter().enumerate() {
+            merge_state_row(
+                &mut self.groups[group_index],
+                values_list,
+                counts_list,
+                row,
+                self.normalize_neg_zero,
+            )?;
+        }
+        Ok(())
+    }
+
+    fn evaluate(&mut self, emit_to: EmitTo) -> Result<ArrayRef> {
+        let emitted = emit_to.take_needed(&mut self.groups);
+        // `ScalarValue::iter_to_array` errors on an empty iterator. The 
grouped-aggregate stream
+        // never emits zero groups, so this is unreachable; assert it rather 
than leaving the
+        // dependency implicit.
+        debug_assert!(!emitted.is_empty(), "mode: evaluate called with no 
groups");
+        let mut results = Vec::with_capacity(emitted.len());
+        for map in &emitted {
+            results.push(eval_mode(map, &self.data_type)?);
+        }
+        ScalarValue::iter_to_array(results)
+    }
+
+    fn state(&mut self, emit_to: EmitTo) -> Result<Vec<ArrayRef>> {
+        let emitted = emit_to.take_needed(&mut self.groups);
+        // As in `evaluate`: `build_state` funnels into 
`ScalarValue::iter_to_array`, which needs a
+        // non-empty iterator.
+        debug_assert!(!emitted.is_empty(), "mode: state called with no 
groups");
+        let refs: Vec<&HashMap<ScalarValue, i64>> = emitted.iter().collect();
+        Ok(vec![Arc::new(build_state(&self.data_type, &refs)?)])
+    }
+
+    fn convert_to_state(
+        &self,
+        _values: &[ArrayRef],
+        _opt_filter: Option<&BooleanArray>,
+    ) -> Result<Vec<ArrayRef>> {
+        not_impl_err!("Input batch conversion to state not implemented")
+    }
+
+    fn size(&self) -> usize {
+        // `size_of_val(self)` only covers the `Vec`'s inline 
pointer/len/capacity, not the slot
+        // array it owns. One empty `HashMap` per group is 48 bytes of slot 
that `map_size` reports
+        // as 0, so a run with many sparse groups would otherwise be invisible 
to the memory pool
+        // that drives spill decisions.
+        size_of_val(self)
+            + self.groups.capacity() * size_of::<HashMap<ScalarValue, i64>>()
+            + self.groups.iter().map(map_size).sum::<usize>()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use arrow::array::{Float64Array, Int32Array};
+    use arrow::datatypes::Int32Type;
+
+    fn i32_array(values: Vec<Option<i32>>) -> ArrayRef {
+        Arc::new(Int32Array::from(values))
+    }
+
+    fn eval_acc(acc: &mut ModeAccumulator) -> ScalarValue {
+        acc.evaluate().unwrap()
+    }
+
+    #[test]
+    fn most_frequent_value() {
+        let mut acc = ModeAccumulator::new(DataType::Int32, false);
+        acc.update_batch(&[i32_array(vec![Some(0), Some(10), Some(10)])])
+            .unwrap();
+        assert_eq!(eval_acc(&mut acc), ScalarValue::Int32(Some(10)));
+    }
+
+    #[test]
+    fn nulls_are_ignored() {
+        let mut acc = ModeAccumulator::new(DataType::Int32, false);
+        acc.update_batch(&[i32_array(vec![
+            Some(10),
+            None,
+            None,
+            None,
+            Some(10),
+            Some(7),
+        ])])
+        .unwrap();
+        assert_eq!(eval_acc(&mut acc), ScalarValue::Int32(Some(10)));
+    }
+
+    #[test]
+    fn empty_input_is_null() {
+        let mut acc = ModeAccumulator::new(DataType::Int32, false);
+        acc.update_batch(&[i32_array(vec![None, None])]).unwrap();
+        assert_eq!(eval_acc(&mut acc), ScalarValue::Int32(None));
+    }
+
+    #[test]
+    fn ties_break_to_smallest() {
+        let mut acc = ModeAccumulator::new(DataType::Int32, false);
+        // 10 and 20 each appear twice; Comet returns the smallest tied value.
+        acc.update_batch(&[i32_array(vec![Some(20), Some(10), Some(10), 
Some(20)])])
+            .unwrap();
+        assert_eq!(eval_acc(&mut acc), ScalarValue::Int32(Some(10)));
+    }
+
+    /// Turn an accumulator's `Vec<ScalarValue>` state into the state arrays 
`merge_batch` consumes.
+    fn state_arrays(acc: &mut ModeAccumulator) -> Vec<ArrayRef> {
+        acc.state()
+            .unwrap()
+            .into_iter()
+            .map(|s| ScalarValue::iter_to_array(vec![s]).unwrap())
+            .collect()
+    }
+
+    #[test]
+    fn merge_matches_single_shot() {
+        let single = {
+            let mut a = ModeAccumulator::new(DataType::Int32, false);
+            a.update_batch(&[i32_array(vec![
+                Some(1),
+                Some(1),
+                Some(2),
+                Some(3),
+                Some(3),
+                Some(3),
+            ])])
+            .unwrap();
+            eval_acc(&mut a)
+        };
+
+        let mut left = ModeAccumulator::new(DataType::Int32, false);
+        left.update_batch(&[i32_array(vec![Some(1), Some(1), Some(3)])])
+            .unwrap();
+        let lstate = state_arrays(&mut left);
+
+        let mut right = ModeAccumulator::new(DataType::Int32, false);
+        right
+            .update_batch(&[i32_array(vec![Some(2), Some(3), Some(3)])])
+            .unwrap();
+        let rstate = state_arrays(&mut right);
+
+        let mut merged = ModeAccumulator::new(DataType::Int32, false);
+        merged.merge_batch(&lstate).unwrap();
+        merged.merge_batch(&rstate).unwrap();
+        assert_eq!(eval_acc(&mut merged), single);
+    }
+
+    /// Input from the SPARK-57329 report: `-0.0` x2, `0.0` x2, `5.0` x3. The 
winner differs
+    /// depending on whether the two zeros share a key, so it pins each 
version's behaviour
+    /// without depending on how `-0.0` and `0.0` compare.
+    fn signed_zero_input() -> ArrayRef {
+        Arc::new(Float64Array::from(vec![
+            Some(-0.0),
+            Some(-0.0),
+            Some(0.0),
+            Some(0.0),
+            Some(5.0),
+            Some(5.0),
+            Some(5.0),
+        ]))
+    }
+
+    #[test]
+    fn signed_zeros_are_distinct_keys_before_spark_42() {
+        // Spark 3.4-4.1 key on `java.lang.Double.equals`, so counts are 
-0.0:2, 0.0:2, 5.0:3 and
+        // 5.0 wins outright.
+        let mut acc = ModeAccumulator::new(DataType::Float64, false);
+        acc.update_batch(&[signed_zero_input()]).unwrap();
+        assert_eq!(eval_acc(&mut acc), ScalarValue::Float64(Some(5.0)));
+    }
+
+    #[test]
+    fn signed_zeros_share_a_key_from_spark_42() {
+        // Spark 4.2.0+ normalizes the key (SPARK-57329), so counts are 0.0:4, 
5.0:3 and the
+        // zero wins.
+        let mut acc = ModeAccumulator::new(DataType::Float64, true);
+        acc.update_batch(&[signed_zero_input()]).unwrap();
+        assert_eq!(eval_acc(&mut acc), ScalarValue::Float64(Some(0.0)));
+    }
+
+    #[test]
+    fn nan_collapses_on_every_version() {
+        // `doubleToLongBits` maps every NaN to one key on all supported 
versions, so the two NaNs
+        // outvote the single 1.0 regardless of the -0.0 setting.
+        for normalize_neg_zero in [false, true] {
+            let mut acc = ModeAccumulator::new(DataType::Float64, 
normalize_neg_zero);
+            let arr: ArrayRef = Arc::new(Float64Array::from(vec![
+                Some(f64::NAN),
+                Some(-f64::NAN),
+                Some(1.0),
+            ]));
+            acc.update_batch(&[arr]).unwrap();
+            match eval_acc(&mut acc) {
+                ScalarValue::Float64(Some(v)) => assert!(
+                    v.is_nan(),
+                    "expected NaN with 
normalize_neg_zero={normalize_neg_zero}, got {v}"
+                ),
+                other => panic!("expected Float64(NaN), got {other:?}"),
+            }
+        }
+    }
+
+    #[test]
+    fn signed_zero_key_survives_merge() {
+        // The partial/final split must not lose the distinction: each side 
sees one -0.0 and one
+        // 0.0, and 5.0 only wins if they stay separate through the merge.
+        let mut left = ModeAccumulator::new(DataType::Float64, false);
+        left.update_batch(&[
+            Arc::new(Float64Array::from(vec![Some(-0.0), Some(0.0), 
Some(5.0)])) as ArrayRef,
+        ])
+        .unwrap();
+        let lstate = state_arrays(&mut left);
+
+        let mut right = ModeAccumulator::new(DataType::Float64, false);
+        right
+            .update_batch(&[Arc::new(Float64Array::from(vec![
+                Some(-0.0),
+                Some(0.0),
+                Some(5.0),
+                Some(5.0),
+            ])) as ArrayRef])
+            .unwrap();
+        let rstate = state_arrays(&mut right);
+
+        let mut merged = ModeAccumulator::new(DataType::Float64, false);
+        merged.merge_batch(&lstate).unwrap();
+        merged.merge_batch(&rstate).unwrap();
+        assert_eq!(eval_acc(&mut merged), ScalarValue::Float64(Some(5.0)));
+    }
+
+    #[test]
+    fn groups_accumulator_per_group_mode() {
+        let mut acc = ModeGroupsAccumulator::new(DataType::Int32, false);
+        let values = i32_array(vec![Some(5), Some(5), Some(9), Some(9), 
Some(9)]);
+        acc.update_batch(&[values], &[0, 0, 1, 1, 1], None, 2)
+            .unwrap();
+        let result = acc.evaluate(EmitTo::All).unwrap();
+        let result = result.as_primitive::<Int32Type>();
+        assert_eq!(result.value(0), 5);
+        assert_eq!(result.value(1), 9);
+    }
+
+    #[test]
+    fn groups_accumulator_merge_roundtrip() {
+        // Partial over two groups, then merge its state into a fresh 
accumulator.
+        let mut partial = ModeGroupsAccumulator::new(DataType::Int32, false);
+        let values = i32_array(vec![Some(5), Some(5), Some(7), Some(9), 
Some(9), Some(9)]);
+        partial
+            .update_batch(&[values], &[0, 0, 0, 1, 1, 1], None, 2)
+            .unwrap();
+        let state = partial.state(EmitTo::All).unwrap();
+
+        let mut final_acc = ModeGroupsAccumulator::new(DataType::Int32, false);
+        final_acc.merge_batch(&state, &[0, 1], 2).unwrap();
+        let result = final_acc.evaluate(EmitTo::All).unwrap();
+        let result = result.as_primitive::<Int32Type>();
+        assert_eq!(result.value(0), 5);
+        assert_eq!(result.value(1), 9);
+    }
+
+    #[test]
+    fn groups_accumulator_size_accounts_for_group_slots() {
+        // All-NULL input leaves every per-group map empty, so `map_size` 
reports 0 for each and the
+        // `Vec` slot array is the only allocation. Reporting only 
`size_of_val(self)` there hides
+        // the whole thing from the memory pool that drives spill decisions.
+        const GROUPS: usize = 10_000;
+        let mut acc = ModeGroupsAccumulator::new(DataType::Int32, false);
+        acc.update_batch(
+            &[i32_array(vec![None; GROUPS])],
+            &(0..GROUPS).collect::<Vec<_>>(),
+            None,
+            GROUPS,
+        )
+        .unwrap();
+
+        let slot_bytes = GROUPS * size_of::<HashMap<ScalarValue, i64>>();
+        assert!(
+            acc.size() >= size_of_val(&acc) + slot_bytes,
+            "size() = {} does not cover {slot_bytes} bytes of empty group 
slots",
+            acc.size()
+        );
+    }
+}
diff --git a/spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala 
b/spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala
index 1fa43f07de..e748e82cd7 100644
--- a/spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala
+++ b/spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala
@@ -415,6 +415,7 @@ object QueryPlanSerde extends Logging with CometExprShim 
with CometTypeShim {
     classOf[Last] -> CometLast,
     classOf[Max] -> CometMax,
     classOf[Min] -> CometMin,
+    classOf[Mode] -> CometMode,
     classOf[Percentile] -> CometPercentile,
     classOf[RegrIntercept] -> CometRegrIntercept,
     classOf[RegrR2] -> CometRegrR2,
diff --git a/spark/src/main/scala/org/apache/comet/serde/aggregates.scala 
b/spark/src/main/scala/org/apache/comet/serde/aggregates.scala
index c811c42559..4f4b0f5f41 100644
--- a/spark/src/main/scala/org/apache/comet/serde/aggregates.scala
+++ b/spark/src/main/scala/org/apache/comet/serde/aggregates.scala
@@ -22,16 +22,16 @@ package org.apache.comet.serde
 import scala.jdk.CollectionConverters._
 
 import org.apache.spark.sql.catalyst.expressions.{Attribute, Cast, Expression, 
Literal}
-import 
org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression, 
ApproximatePercentile, Average, BitAndAgg, BitOrAgg, BitXorAgg, 
BloomFilterAggregate, CentralMomentAgg, CollectList, CollectSet, Corr, Count, 
Covariance, CovPopulation, CovSample, First, HyperLogLogPlusPlus, Last, Max, 
Min, Percentile, RegrIntercept, RegrR2, RegrReplacement, RegrSlope, RegrSXY, 
StddevPop, StddevSamp, Sum, VariancePop, VarianceSamp}
+import 
org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression, 
ApproximatePercentile, Average, BitAndAgg, BitOrAgg, BitXorAgg, 
BloomFilterAggregate, CentralMomentAgg, CollectList, CollectSet, Corr, Count, 
Covariance, CovPopulation, CovSample, First, HyperLogLogPlusPlus, Last, Max, 
Min, Mode, Percentile, RegrIntercept, RegrR2, RegrReplacement, RegrSlope, 
RegrSXY, StddevPop, StddevSamp, Sum, VariancePop, VarianceSamp}
 import org.apache.spark.sql.catalyst.util.ArrayData
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, 
DataType, DateType, DecimalType, DoubleType, FloatType, IntegerType, LongType, 
NumericType, ShortType, StringType, TimestampNTZType, TimestampType}
 
 import org.apache.comet.CometConf.COMET_EXEC_STRICT_FLOATING_POINT
-import org.apache.comet.CometSparkSessionExtensions.{isSpark35Plus, 
isSpark41Plus, withFallbackReason}
+import org.apache.comet.CometSparkSessionExtensions.{isSpark35Plus, 
isSpark41Plus, isSpark42Plus, withFallbackReason}
 import org.apache.comet.expressions.CometEvalMode
 import org.apache.comet.serde.QueryPlanSerde.{evalModeToProto, exprToProto, 
serializeDataType}
-import org.apache.comet.shims.{CometCollectShim, CometEvalModeUtil}
+import org.apache.comet.shims.{CometCollectShim, CometEvalModeUtil, 
CometTypeShim}
 
 object CometMin extends CometAggregateExpressionSerde[Min] {
 
@@ -1116,6 +1116,78 @@ object CometApproxCountDistinct extends 
CometAggregateExpressionSerde[HyperLogLo
   }
 }
 
+object CometMode extends CometAggregateExpressionSerde[Mode] with 
CometTypeShim {
+
+  private val tieBreakReason =
+    "mode breaks ties non-deterministically in Spark (the result depends on 
JVM hash-map" +
+      " iteration order); Comet returns the smallest of the tied values 
instead" +
+      " (https://github.com/apache/datafusion-comet/issues/3970)"
+
+  override def getIncompatibleReasons(): Seq[String] = Seq(tieBreakReason)
+
+  private def isSupportedType(dt: DataType): Boolean = dt match {
+    case BooleanType => true
+    case ByteType | ShortType | IntegerType | LongType => true
+    case FloatType | DoubleType => true
+    case _: DecimalType => true
+    case DateType | TimestampType | TimestampNTZType => true
+    case StringType => true
+    case _ => false
+  }
+
+  override def getSupportLevel(expr: Mode): SupportLevel = {
+    if (modeHasUnsupportedOrdering(expr)) {
+      // `mode(col, deterministic)` and `mode() WITHIN GROUP (ORDER BY col)` 
carry deterministic
+      // ordered tie-breaking that Comet does not implement yet (Spark 4.0+ 
only).
+      // TODO the ASC form (`reverseOpt = Some(false)`) returns the smallest 
tied value, which is
+      // exactly Comet's tie-break, so it could be served natively as 
`Compatible`.
+      // https://github.com/apache/datafusion-comet/issues/3970
+      Unsupported(
+        Some("mode with a deterministic flag or WITHIN GROUP ordering is not 
supported"))
+    } else if (hasNonDefaultStringCollation(expr.child.dataType)) {
+      // Native counting is not collation-aware, so non-UTF8_BINARY collations 
would group keys
+      // differently from Spark.
+      Unsupported(
+        Some(
+          "mode does not support non-UTF8_BINARY collations " +
+            "(https://github.com/apache/datafusion-comet/issues/2190)"))
+    } else if (!isSupportedType(expr.child.dataType)) {
+      Unsupported(Some(s"mode does not support input type 
${expr.child.dataType}"))
+    } else {
+      Incompatible(Some(tieBreakReason))
+    }
+  }
+
+  override def convert(
+      aggExpr: AggregateExpression,
+      expr: Mode,
+      inputs: Seq[Attribute],
+      binding: Boolean,
+      conf: SQLConf): Option[ExprOuterClass.AggExpr] = {
+    val child = expr.child
+    val childExpr = exprToProto(child, inputs, binding)
+    val dataType = serializeDataType(child.dataType)
+
+    if (childExpr.isDefined && dataType.isDefined) {
+      val builder = ExprOuterClass.Mode.newBuilder()
+      builder.setChild(childExpr.get)
+      builder.setDatatype(dataType.get)
+      // Spark 4.2.0 (SPARK-57329) normalizes `-0.0` to `0.0` before keying 
the frequency map;
+      // earlier versions key the raw boxed value, where 
`java.lang.Double.equals` keeps `-0.0`
+      // and `0.0` apart. The native side has to match whichever Spark we are 
running against.
+      builder.setNormalizeNegZero(isSpark42Plus)
+      Some(
+        ExprOuterClass.AggExpr
+          .newBuilder()
+          .setMode(builder)
+          .build())
+    } else {
+      withFallbackReason(aggExpr, "Child expression or data type not 
supported")
+      None
+    }
+  }
+}
+
 object AggSerde {
   import org.apache.spark.sql.types._
 
diff --git a/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala 
b/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
index a4a4c9d49e..3aa6b7220d 100644
--- a/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
+++ b/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
@@ -31,7 +31,7 @@ import org.apache.spark.internal.Logging
 import org.apache.spark.rdd.RDD
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.expressions.{Ascending, Attribute, 
AttributeSeq, AttributeSet, Expression, ExpressionSet, Generator, 
NamedExpression, SortOrder}
-import 
org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression, 
AggregateMode, CollectList, CollectSet, Final, Partial, PartialMerge, 
Percentile}
+import 
org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression, 
AggregateMode, CollectList, CollectSet, Final, Mode, Partial, PartialMerge, 
Percentile}
 import org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildRight, 
BuildSide}
 import org.apache.spark.sql.catalyst.plans._
 import org.apache.spark.sql.catalyst.plans.physical._
@@ -44,7 +44,7 @@ import 
org.apache.spark.sql.execution.aggregate.{BaseAggregateExec, HashAggregat
 import org.apache.spark.sql.execution.exchange.ReusedExchangeExec
 import org.apache.spark.sql.execution.joins.{BroadcastHashJoinExec, 
BroadcastNestedLoopJoinExec, HashJoin, ShuffledHashJoinExec, SortMergeJoinExec}
 import org.apache.spark.sql.execution.metric.{SQLMetric, SQLMetrics}
-import org.apache.spark.sql.types.{ArrayType, BooleanType, ByteType, DataType, 
DateType, DecimalType, DoubleType, FloatType, IntegerType, LongType, MapType, 
ShortType, StringType, TimestampNTZType, TimestampType}
+import org.apache.spark.sql.types.{ArrayType, BooleanType, ByteType, DataType, 
DateType, DecimalType, DoubleType, FloatType, IntegerType, LongType, MapType, 
ShortType, StringType, StructField, StructType, TimestampNTZType, TimestampType}
 import org.apache.spark.sql.vectorized.ColumnarBatch
 import org.apache.spark.util.SerializableConfiguration
 import org.apache.spark.util.io.ChunkedByteBuffer
@@ -1906,6 +1906,19 @@ trait CometBaseAggregate {
           // Comet casts the child to double, so the native state is 
ArrayType(DoubleType).
           val nativeStateType = ArrayType(DoubleType, containsNull = true)
           output(bufferIdx) = output(bufferIdx).withDataType(nativeStateType)
+        case m: Mode =>
+          // Comet's native mode accumulator keeps a frequency map encoded as 
parallel arrays
+          // (see ModeAccumulator in native/spark-expr): a struct of the 
distinct values and their
+          // counts.
+          val elementType = m.child.dataType
+          val nativeStateType = StructType(
+            Seq(
+              StructField(
+                "values",
+                ArrayType(elementType, containsNull = true),
+                nullable = false),
+              StructField("counts", ArrayType(LongType, containsNull = true), 
nullable = false)))
+          output(bufferIdx) = output(bufferIdx).withDataType(nativeStateType)
         case _ =>
       }
       bufferIdx += bufferAttrs.length
diff --git 
a/spark/src/main/spark-3.x/org/apache/comet/shims/CometTypeShim.scala 
b/spark/src/main/spark-3.x/org/apache/comet/shims/CometTypeShim.scala
index 7280fdc9dc..1e5d1686f9 100644
--- a/spark/src/main/spark-3.x/org/apache/comet/shims/CometTypeShim.scala
+++ b/spark/src/main/spark-3.x/org/apache/comet/shims/CometTypeShim.scala
@@ -24,6 +24,7 @@ import java.nio.charset.{CharacterCodingException, 
CodingErrorAction, StandardCh
 
 import scala.annotation.nowarn
 
+import org.apache.spark.sql.catalyst.expressions.aggregate.Mode
 import org.apache.spark.sql.types.{DataType, StructType}
 import org.apache.spark.unsafe.types.UTF8String
 
@@ -31,6 +32,11 @@ trait CometTypeShim {
   @nowarn // Spark 4 feature; stubbed to false in Spark 3.x for compatibility.
   def isStringCollationType(dt: DataType): Boolean = false
 
+  // `mode() WITHIN GROUP (ORDER BY ...)` and the deterministic-flag form 
(which set `reverseOpt`)
+  // are Spark 4.0 features; Spark 3.x `Mode` is always the plain `mode(col)` 
form.
+  @nowarn
+  def modeHasUnsupportedOrdering(expr: Mode): Boolean = false
+
   @nowarn // Spark 4 feature; stubbed to false in Spark 3.x for compatibility.
   def hasNonDefaultStringCollation(dt: DataType): Boolean = false
 
diff --git 
a/spark/src/main/spark-4.x/org/apache/comet/shims/CometTypeShim.scala 
b/spark/src/main/spark-4.x/org/apache/comet/shims/CometTypeShim.scala
index 1270eebfe0..8392aa76af 100644
--- a/spark/src/main/spark-4.x/org/apache/comet/shims/CometTypeShim.scala
+++ b/spark/src/main/spark-4.x/org/apache/comet/shims/CometTypeShim.scala
@@ -19,11 +19,17 @@
 
 package org.apache.comet.shims
 
+import org.apache.spark.sql.catalyst.expressions.aggregate.Mode
 import org.apache.spark.sql.execution.datasources.VariantMetadata
 import org.apache.spark.sql.types.{ArrayType, DataType, MapType, StringType, 
StructType, VariantType}
 import org.apache.spark.unsafe.types.UTF8String
 
 trait CometTypeShim {
+  // `reverseOpt` is set for `mode() WITHIN GROUP (ORDER BY col [DESC])` and 
the
+  // `mode(col, deterministic)` form, both of which carry ordered tie-breaking 
that Comet does not
+  // implement yet. The plain `mode(col)` form leaves it as `None`.
+  def modeHasUnsupportedOrdering(expr: Mode): Boolean = 
expr.reverseOpt.isDefined
+
   // A `StringType` carries collation metadata in Spark 4.0. Only non-default 
(non-UTF8_BINARY)
   // collations have semantics Comet's byte-level hashing/sorting/equality 
cannot honor. The
   // default `StringType` object is `StringType(UTF8_BINARY_COLLATION_ID)`, so 
comparing
diff --git a/spark/src/test/resources/sql-tests/expressions/aggregate/mode.sql 
b/spark/src/test/resources/sql-tests/expressions/aggregate/mode.sql
new file mode 100644
index 0000000000..f32e4905d0
--- /dev/null
+++ b/spark/src/test/resources/sql-tests/expressions/aggregate/mode.sql
@@ -0,0 +1,253 @@
+-- Licensed to the Apache Software Foundation (ASF) under one
+-- or more contributor license agreements.  See the NOTICE file
+-- distributed with this work for additional information
+-- regarding copyright ownership.  The ASF licenses this file
+-- to you under the Apache License, Version 2.0 (the
+-- "License"); you may not use this file except in compliance
+-- with the License.  You may obtain a copy of the License at
+--
+--   http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing,
+-- software distributed under the License is distributed on an
+-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+-- KIND, either express or implied.  See the License for the
+-- specific language governing permissions and limitations
+-- under the License.
+
+-- Comet's `mode` is opt-in via allowIncompatible because Spark breaks ties 
non-deterministically.
+-- Every compared query below has a single value with the strictly-highest 
frequency per group so
+-- that Comet's smallest-value tie-break agrees with Spark's arbitrary choice.
+-- Config: spark.comet.expression.Mode.allowIncompatible=true
+
+-- ============================================================
+-- Setup: tables
+-- ============================================================
+
+statement
+CREATE TABLE mode_int(v int, grp string) USING parquet
+
+statement
+INSERT INTO mode_int VALUES
+  (10, 'a'), (10, 'a'), (7, 'a'), (NULL, 'a'),
+  (5, 'b'), (5, 'b'), (5, 'b'), (9, 'b'), (NULL, 'b'),
+  (NULL, 'c'), (NULL, 'c')
+
+statement
+CREATE TABLE mode_all_null(v int) USING parquet
+
+statement
+INSERT INTO mode_all_null VALUES (NULL), (NULL)
+
+-- ============================================================
+-- Global aggregate (no GROUP BY): unique mode
+-- ============================================================
+
+query
+SELECT mode(v) FROM mode_int
+
+-- ============================================================
+-- GROUP BY: unique mode per group; NULLs ignored
+-- ============================================================
+
+query
+SELECT grp, mode(v) FROM mode_int GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- All-NULL input returns NULL
+-- ============================================================
+
+query
+SELECT mode(v) FROM mode_all_null
+
+-- ============================================================
+-- Mixed with other aggregates
+-- ============================================================
+
+query
+SELECT grp, mode(v), count(*), sum(v) FROM mode_int GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- HAVING clause
+-- ============================================================
+
+query
+SELECT grp, mode(v) FROM mode_int GROUP BY grp HAVING count(v) > 3 ORDER BY grp
+
+-- ============================================================
+-- Boolean
+-- ============================================================
+
+statement
+CREATE TABLE mode_bool(v boolean, grp string) USING parquet
+
+statement
+INSERT INTO mode_bool VALUES
+  (true, 'a'), (true, 'a'), (false, 'a'), (NULL, 'a'),
+  (false, 'b'), (false, 'b'), (true, 'b')
+
+query
+SELECT grp, mode(v) FROM mode_bool GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Byte / Short / Long
+-- ============================================================
+
+statement
+CREATE TABLE mode_nums(b tinyint, s smallint, l bigint, grp string) USING 
parquet
+
+statement
+INSERT INTO mode_nums VALUES
+  (1, 100, 1000000000000, 'a'), (1, 100, 1000000000000, 'a'), (2, 200, 
2000000000000, 'a'),
+  (3, 300, 3000000000000, 'b'), (3, 300, 3000000000000, 'b'), (4, 400, 
4000000000000, 'b')
+
+query
+SELECT grp, mode(b), mode(s), mode(l) FROM mode_nums GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Float / Double
+--
+-- Whether `-0.0` and `0.0` share a frequency-map key is version-dependent: 
Spark 3.4-4.1 key on
+-- `java.lang.Double.equals` and keep them apart, while Spark 4.2.0+ folds 
`-0.0` into `0.0`
+-- (SPARK-57329). Each group below still has a unique winner under both 
behaviours.
+--
+-- Negative zero must be written as `-0.0D`, not `CAST(-0.0 AS DOUBLE)`: an 
unsuffixed `-0.0` is a
+-- DecimalType literal, and Decimal has no signed zero, so the cast yields 
`+0.0` and the column
+-- would silently contain no negative zeros at all.
+-- ============================================================
+
+statement
+CREATE TABLE mode_double(v double, grp string) USING parquet
+
+statement
+INSERT INTO mode_double VALUES
+  (1.5, 'a'), (1.5, 'a'), (2.5, 'a'), (NULL, 'a'),
+  (0.0D, 'b'), (-0.0D, 'b'), (-0.0D, 'b'), (7.0, 'b')
+
+query
+SELECT grp, mode(v) FROM mode_double GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Signed zeros: which value wins depends on whether -0.0 and 0.0 share a key
+--
+-- The counts are -0.0:2, 0.0:2, 5.0:3 (the SPARK-57329 reproducer). Keeping 
the zeros apart makes
+-- 5.0 the unique winner; folding them together makes the zero win with 4. The 
two candidate
+-- answers differ in magnitude, so this catches the divergence even where a 
`-0.0` vs `0.0`
+-- difference would not be visible. Neither behaviour produces a tie, so there 
is no dependence on
+-- Spark's non-deterministic tie-break.
+-- ============================================================
+
+statement
+CREATE TABLE mode_signed_zero(v double, grp string) USING parquet
+
+statement
+INSERT INTO mode_signed_zero VALUES
+  (-0.0D, 'a'), (-0.0D, 'a'),
+  (0.0D, 'a'), (0.0D, 'a'),
+  (5.0D, 'a'), (5.0D, 'a'), (5.0D, 'a')
+
+query
+SELECT grp, mode(v) FROM mode_signed_zero GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- NaN collapses to a single key on every supported version
+--
+-- `doubleToLongBits` maps every NaN bit pattern to one value, so the two NaNs 
outvote the single
+-- 1.0 and the mode is NaN.
+-- ============================================================
+
+statement
+CREATE TABLE mode_nan(v double, grp string) USING parquet
+
+statement
+INSERT INTO mode_nan VALUES
+  (CAST('NaN' AS DOUBLE), 'a'), (CAST('NaN' AS DOUBLE), 'a'), (1.0D, 'a')
+
+query
+SELECT grp, mode(v) FROM mode_nan GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Decimal
+-- ============================================================
+
+statement
+CREATE TABLE mode_decimal(v decimal(10,2), grp string) USING parquet
+
+statement
+INSERT INTO mode_decimal VALUES
+  (1.50, 'a'), (1.50, 'a'), (2.50, 'a'), (NULL, 'a'),
+  (99999999.99, 'b'), (99999999.99, 'b'), (0.00, 'b')
+
+query
+SELECT grp, mode(v) FROM mode_decimal GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- String
+-- ============================================================
+
+statement
+CREATE TABLE mode_string(v string, grp string) USING parquet
+
+statement
+INSERT INTO mode_string VALUES
+  ('hello', 'a'), ('hello', 'a'), ('world', 'a'), (NULL, 'a'),
+  ('', 'b'), ('', 'b'), ('x', 'b')
+
+query
+SELECT grp, mode(v) FROM mode_string GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Date / Timestamp
+-- ============================================================
+
+statement
+CREATE TABLE mode_temporal(d date, t timestamp, grp string) USING parquet
+
+statement
+INSERT INTO mode_temporal VALUES
+  (DATE '2024-01-01', TIMESTAMP '2024-01-01 00:00:00', 'a'),
+  (DATE '2024-01-01', TIMESTAMP '2024-01-01 00:00:00', 'a'),
+  (DATE '2024-06-15', TIMESTAMP '2024-06-15 12:30:00', 'a'),
+  (DATE '1970-01-01', TIMESTAMP '1970-01-01 00:00:00', 'b'),
+  (DATE '1970-01-01', TIMESTAMP '1970-01-01 00:00:00', 'b'),
+  (DATE '2000-12-31', TIMESTAMP '2000-12-31 23:59:59', 'b')
+
+query
+SELECT grp, mode(d), mode(t) FROM mode_temporal GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- TimestampNTZ (declared supported by isSupportedType, so exercise it 
directly)
+-- ============================================================
+
+statement
+CREATE TABLE mode_ntz(t timestamp_ntz, grp string) USING parquet
+
+statement
+INSERT INTO mode_ntz VALUES
+  (TIMESTAMP_NTZ '2024-01-01 00:00:00', 'a'),
+  (TIMESTAMP_NTZ '2024-01-01 00:00:00', 'a'),
+  (TIMESTAMP_NTZ '2024-06-15 12:30:00', 'a'),
+  (NULL, 'a'),
+  (TIMESTAMP_NTZ '1970-01-01 00:00:00', 'b'),
+  (TIMESTAMP_NTZ '1970-01-01 00:00:00', 'b'),
+  (TIMESTAMP_NTZ '2000-12-31 23:59:59', 'b')
+
+query
+SELECT grp, mode(t) FROM mode_ntz GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Unsupported input type falls back to Spark
+--
+-- A single row keeps the result deterministic: Spark's mode on BinaryType 
compares Array[Byte]
+-- keys by reference, so any binary multiset with repeats is a full tie and 
returns an arbitrary
+-- value. One row avoids that while still exercising the unsupported-type 
fallback.
+-- ============================================================
+
+statement
+CREATE TABLE mode_binary(v binary) USING parquet
+
+statement
+INSERT INTO mode_binary VALUES (X'CAFE')
+
+query expect_fallback(does not support input type)
+SELECT mode(v) FROM mode_binary
diff --git 
a/spark/src/test/resources/sql-tests/expressions/aggregate/mode_within_group.sql
 
b/spark/src/test/resources/sql-tests/expressions/aggregate/mode_within_group.sql
new file mode 100644
index 0000000000..1843139648
--- /dev/null
+++ 
b/spark/src/test/resources/sql-tests/expressions/aggregate/mode_within_group.sql
@@ -0,0 +1,70 @@
+-- Licensed to the Apache Software Foundation (ASF) under one
+-- or more contributor license agreements.  See the NOTICE file
+-- distributed with this work for additional information
+-- regarding copyright ownership.  The ASF licenses this file
+-- to you under the Apache License, Version 2.0 (the
+-- "License"); you may not use this file except in compliance
+-- with the License.  You may obtain a copy of the License at
+--
+--   http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing,
+-- software distributed under the License is distributed on an
+-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+-- KIND, either express or implied.  See the License for the
+-- specific language governing permissions and limitations
+-- under the License.
+
+-- The ordered forms of `mode` set `reverseOpt`, which gives them 
deterministic tie-breaking that
+-- Comet does not implement, so they must fall back to Spark. 
`allowIncompatible` is enabled so
+-- that the ordering check is the only thing that can trigger a fallback; 
without it every query
+-- here would fall back for the unrelated tie-break reason and the assertions 
would be vacuous.
+--
+-- `mode(col, reverse)` and `mode() WITHIN GROUP (ORDER BY col)` are Spark 4.0 
features; on Spark
+-- 3.x `Mode` only has the plain `mode(col)` form, which mode.sql covers.
+-- MinSparkVersion: 4.0
+-- Config: spark.comet.expression.Mode.allowIncompatible=true
+
+statement
+CREATE TABLE mode_ordered(v int, grp string) USING parquet
+
+statement
+INSERT INTO mode_ordered VALUES
+  (10, 'a'), (10, 'a'), (7, 'a'), (NULL, 'a'),
+  (5, 'b'), (5, 'b'), (5, 'b'), (9, 'b')
+
+-- ============================================================
+-- Sentinel: the plain form still runs natively under this config, so a silent 
whole-expression
+-- regression cannot make the fallback assertions below pass vacuously.
+-- ============================================================
+
+query
+SELECT grp, mode(v) FROM mode_ordered GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Deterministic-flag form
+--
+-- `ModeBuilder` only sets `reverseOpt` for `mode(col, true)`; it rewrites 
`mode(col, false)` to the
+-- plain `Mode(child)` (Mode.scala, `ModeBuilder.build`), so the `false` form 
must still run
+-- natively. That asymmetry is the reason `modeHasUnsupportedOrdering` keys 
off `reverseOpt` rather
+-- than off the argument count.
+-- ============================================================
+
+query
+SELECT mode(v, false) FROM mode_ordered
+
+query expect_fallback(mode with a deterministic flag or WITHIN GROUP ordering 
is not supported)
+SELECT mode(v, true) FROM mode_ordered
+
+-- ============================================================
+-- WITHIN GROUP form, ascending and descending
+-- ============================================================
+
+query expect_fallback(mode with a deterministic flag or WITHIN GROUP ordering 
is not supported)
+SELECT mode() WITHIN GROUP (ORDER BY v) FROM mode_ordered
+
+query expect_fallback(mode with a deterministic flag or WITHIN GROUP ordering 
is not supported)
+SELECT mode() WITHIN GROUP (ORDER BY v DESC) FROM mode_ordered
+
+query expect_fallback(mode with a deterministic flag or WITHIN GROUP ordering 
is not supported)
+SELECT grp, mode() WITHIN GROUP (ORDER BY v) FROM mode_ordered GROUP BY grp 
ORDER BY grp


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to