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 671a8dd9ee fix: make native cast from float/double to decimal 
compatible with Spark (#5136)
671a8dd9ee is described below

commit 671a8dd9ee4c4f5bf44f3096ab83b2b30801fcb8
Author: Andy Grove <[email protected]>
AuthorDate: Sat Aug 1 14:03:33 2026 -0600

    fix: make native cast from float/double to decimal compatible with Spark 
(#5136)
    
    * fix: make cast from float/double to decimal compatible with Spark
    
    Spark casts float/double to decimal by rounding the shortest decimal
    string form of the value (Double.toString followed by
    BigDecimal.setScale with HALF_UP), while Comet rounded the binary
    value, producing different results for values such as 0.5153125 whose
    binary form sits just below the rounding tie that the string form
    lands on.
    
    Convert through the shortest round-trip decimal form (via ryu) with
    integer rescaling, keeping the vectorized binary path for values
    provably far from a rounding tie. Also match Spark by returning null
    for NaN and infinity even in ANSI mode.
    
    Closes #1371
    
    * refactor: simplify float to decimal cast internals
    
    Reuse the existing pow10_i128 lookup table instead of computing powers
    of ten per element, which also removes the hand-maintained exponent
    bound and the redundant zero-mantissa branch. Collapse repeated test
    boilerplate behind helpers on both the Rust and Scala sides, and
    clarify that the overflow pre-check is a perf-only guard and that
    high-scale targets commonly take the exact path.
    
    * test: benchmark float and double to decimal casts
    
    Add FLOAT/DOUBLE to DECIMAL(10,2) and DECIMAL(38,18) cases so the
    numeric cast benchmark covers both the guarded binary fast path and the
    shortest-decimal-string path of the native float-to-decimal cast.
    
    * test: regenerate TPC-DS plan stability golden files
    
    Casting float/double to decimal is now compatible, so aggregates over
    decimal columns no longer report a Cast fallback in the plan output.
    
    * review: share the HALF_UP and digit-scan helpers, note the JDK 19 caveat
    
    Addresses review feedback on #5136:
    
    - `float_to_decimal128` uses a plain `shift as u32` cast instead of a
      fallible `try_into().ok()?`; the branch already knows `shift >= 0`, so
      the conversion can never fail and the sibling `else` branch and every
      other `pow10_i128` caller already spell it this way.
    - Factor the HALF_UP-by-power-of-ten integer division into
      `div_round_half_up_i128` next to `pow10_i128` in `string.rs` and call
      it from both `float_to_decimal128` and `parse_string_to_decimal`,
      instead of keeping two copies of the same quotient/remainder rounding.
      The `checked_div` guard the string path had is dropped: the divisor is
      always `10^n` with `n >= 1`, so it can never be zero.
    - Rewrite `parse_decimal_notation` on top of the existing
      `digits_to_i128` helper, splitting off the sign and the `.` the way
      `parse_string_to_decimal` does, rather than hand-rolling a second digit
      accumulator. It now returns `Option` so the (unreachable) overflow case
      propagates instead of wrapping. A new test round-trips every shape ryu
      can emit, including `f64::MAX` and the smallest subnormal.
    - Float/double to decimal reports `Compatible(Some(...))` with the JDK 19
      caveat: `Double.toString` only emits the shortest round-trip form on
      JDK 19 and later, so on older JDKs Spark's own result can differ for a
      value whose shortest form lands exactly on a rounding tie. The cast
      audit doc records the same note.
    
    * test: use CometColumnarToRow in q13 and q44 plan stability goldens
    
    Native columnar-to-row conversion is now disabled by default (#5114), so
    plans render CometColumnarToRow. The merge from main kept the branch side
    of the adjacent COMET-INFO edits in these four files, leaving the stale
    CometNativeColumnarToRow name behind.
---
 .../expression-audits/conversion_funcs.md          |   2 +-
 native/Cargo.lock                                  |   1 +
 native/spark-expr/Cargo.toml                       |   3 +
 native/spark-expr/src/conversion_funcs/numeric.rs  | 317 +++++++++++++++++----
 native/spark-expr/src/conversion_funcs/string.rs   |  45 ++-
 .../org/apache/comet/expressions/CometCast.scala   |  15 +-
 .../approved-plans-v1_4-spark3_5/q44/extended.txt  |   6 +-
 .../approved-plans-v1_4-spark4_0/q44/extended.txt  |   6 +-
 .../approved-plans-v1_4-spark4_0/q6/extended.txt   |   2 +-
 .../approved-plans-v1_4/q13/extended.txt           |   2 +-
 .../approved-plans-v1_4/q26/extended.txt           |   2 +-
 .../approved-plans-v1_4/q27/extended.txt           |   2 +-
 .../approved-plans-v1_4/q28/extended.txt           |  12 +-
 .../approved-plans-v1_4/q32/extended.txt           |   2 +-
 .../approved-plans-v1_4/q44/extended.txt           |   8 +-
 .../approved-plans-v1_4/q6/extended.txt            |   2 +-
 .../approved-plans-v1_4/q7/extended.txt            |   2 +-
 .../approved-plans-v1_4/q85/extended.txt           |   2 +-
 .../approved-plans-v1_4/q9/extended.txt            |  10 +-
 .../approved-plans-v1_4/q92/extended.txt           |   2 +-
 .../approved-plans-v2_7-spark4_0/q6/extended.txt   |   2 +-
 .../approved-plans-v2_7/q27a/extended.txt          |   6 +-
 .../approved-plans-v2_7/q6/extended.txt            |   2 +-
 .../scala/org/apache/comet/CometCastSuite.scala    |  56 ++--
 .../apache/comet/exec/CometAggregateSuite.scala    |   8 +-
 .../CometCastNumericToNumericBenchmark.scala       |  12 +-
 26 files changed, 392 insertions(+), 137 deletions(-)

diff --git 
a/docs/source/contributor-guide/expression-audits/conversion_funcs.md 
b/docs/source/contributor-guide/expression-audits/conversion_funcs.md
index c11d441a23..64e01822dc 100644
--- a/docs/source/contributor-guide/expression-audits/conversion_funcs.md
+++ b/docs/source/contributor-guide/expression-audits/conversion_funcs.md
@@ -33,7 +33,7 @@
   - Spark 4.1 `TimeType` casts have no explicit `Unsupported` arm; they fall 
back implicitly but do not appear in the auto-generated compatibility doc 
([#4490](https://github.com/apache/datafusion-comet/issues/4490)).
   - `CAST(<map> AS <map>)` runs natively via `cast_map_to_map`; 
`CometCast.isSupported` recurses into the key and value casts, so the map arm 
inherits whichever support level the inner casts report.
   - `spark.sql.legacy.castComplexTypesToString.enabled=true` is `Unsupported` 
for any array/map/struct-to-string cast, so those casts fall back to Spark 
while the flag is on. Comet only implements the default (`{}`-wrapped, 
NULL-rendering) formatting. The flag is internal from Spark 4.0 onward and 
defaults to false.
-  - `CAST(<float|double> AS DECIMAL)` rounding may differ from Spark 
(`Incompatible`, gated by `spark.comet.expression.Cast.allowIncompatible`, 
tracked at [#1371](https://github.com/apache/datafusion-comet/issues/1371)).
+  - `CAST(<float|double> AS DECIMAL)` is `Compatible`: it rounds the shortest 
decimal string form of the value (matching Spark's `Double.toString` + 
`BigDecimal.setScale(HALF_UP)` path) rather than the binary value, taking a 
guarded binary fast path for values provably far from a rounding tie. NaN and 
infinity are null even in ANSI mode, matching Spark, which swallows the 
`NumberFormatException` from `Decimal(double)` before the ANSI overflow check. 
The support level carries a note: `Dou [...]
 - Spark registers the type-name conversion functions (`bigint`, `binary`, 
`boolean`, `date`, `decimal`, `double`, `float`, `int`, `smallint`, `string`, 
`timestamp`, `tinyint`) as cast aliases. Each lowers to the same `Cast` node, 
so Comet handles it via the `cast` implementation with the same compatibility 
profile.
 - Performance (tuned 2026-07-14, PR 
[#4920](https://github.com/apache/datafusion-comet/pull/4920)): narrowing 
integer casts (`spark_cast_int_to_int`) map the values buffer in a single pass 
with Arrow `unary`/`try_unary` and carry the null buffer over untouched, 
replacing an element-by-element `Option`/`Result` iterator-collect. Up to 100x 
faster on narrowing casts. Benchmark: `benches/cast_numeric.rs`.
 - Performance (tuned 2026-07-15, PR 
[#4940](https://github.com/apache/datafusion-comet/pull/4940)): 
float/double-to-decimal casts (`cast_floating_point_to_decimal128`) now convert 
in a single vectorized `unary_opt` pass that maps out-of-range values (NaN, 
infinity, precision overflow) to null, replacing the per-element 
`Decimal128Builder` loop. ANSI raises via an O(1) null-count check plus a rare 
element-wise rescan. 15-36% faster with no regression on any shape. Benchmark: 
`benches/cast [...]
diff --git a/native/Cargo.lock b/native/Cargo.lock
index a920991922..54b748cd9b 100644
--- a/native/Cargo.lock
+++ b/native/Cargo.lock
@@ -2071,6 +2071,7 @@ dependencies = [
  "num",
  "rand 0.10.2",
  "regex",
+ "ryu",
  "serde",
  "serde_json",
  "tokio",
diff --git a/native/spark-expr/Cargo.toml b/native/spark-expr/Cargo.toml
index 477de86fe2..775311935c 100644
--- a/native/spark-expr/Cargo.toml
+++ b/native/spark-expr/Cargo.toml
@@ -43,6 +43,9 @@ futures = { workspace = true }
 twox-hash = "2.1.2"
 rand = { workspace = true }
 base64 = "0.23.0"
+# shortest round-trip float formatting, used to match Spark's 
Double.toString-based
+# float/double to decimal conversion
+ryu = "1.0"
 uuid = "1.23.3"
 
 [dev-dependencies]
diff --git a/native/spark-expr/src/conversion_funcs/numeric.rs 
b/native/spark-expr/src/conversion_funcs/numeric.rs
index cb141880d0..90fc5ffc5b 100644
--- a/native/spark-expr/src/conversion_funcs/numeric.rs
+++ b/native/spark-expr/src/conversion_funcs/numeric.rs
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use crate::conversion_funcs::string::{digits_to_i128, div_round_half_up_i128, 
pow10_i128};
 use crate::conversion_funcs::utils::cast_overflow;
 use crate::conversion_funcs::utils::MICROS_PER_SECOND;
 use crate::{EvalMode, SparkError, SparkResult};
@@ -899,33 +900,54 @@ where
 {
     let input = array.as_any().downcast_ref::<PrimitiveArray<T>>().unwrap();
     let mul = 10_f64.powi(scale as i32);
-
-    // Single vectorized pass: a value with no in-range integer form (NaN / 
infinity) or that
-    // does not fit the output precision maps to null. `unary_opt` only 
applies the closure to
-    // non-null slots and carries the input null buffer over, so it replaces 
the per-element
-    // builder loop without a second pass.
+    // Perf-only early out: both paths below already map overflow to null, but 
this skips
+    // the exact-path string formatting for values (including ±infinity) whose 
scaled
+    // magnitude clearly exceeds the precision limit no matter what their 
exact decimal
+    // digits are. The slack generously covers the same scaling error that 
TIE_GUARD below
+    // bounds, so borderline values still take the exact path.
+    let overflow_bound = 10_f64.powi(precision as i32) * (1.0 + 1e-12);
+
+    // A value with no decimal form (NaN / infinity) or that does not fit the 
output
+    // precision maps to null. `unary_opt` only applies the closure to 
non-null slots and
+    // carries the input null buffer over. Floats are widened to double first 
because Spark
+    // casts through `x.toDouble` before converting to a string.
     let result: Decimal128Array = input.unary_opt::<_, Decimal128Type>(|v| {
         let f: f64 = v.as_();
-        (f * mul)
-            .round()
-            .to_i128()
-            .filter(|x| is_validate_decimal_precision(*x, precision))
+        let x = f * mul;
+        if x.abs() > overflow_bound {
+            // also catches ±infinity inputs, which Spark nulls
+            return None;
+        }
+        // Rounding the binary product agrees with Spark's string-based result 
whenever
+        // every value within the combined error of the shortest-string 
representation,
+        // the multiplication, and `powi` (a few ulp, at most |x| * 2^-50) 
rounds to the
+        // same integer: |x - round(x)| + |x| * TIE_GUARD < 0.5, with 
TIE_GUARD at 4x the
+        // error bound. For |x| > 2^47 the guard exceeds 0.5, so large values, 
whose
+        // integer neighborhoods are wider than 1, always take the exact path; 
for
+        // high-scale targets (e.g. Decimal(38,18)) that makes the exact path 
the common
+        // path, not a rare fallback. NaN fails the comparison and takes the 
exact path,
+        // which nulls it.
+        const TIE_GUARD: f64 = 3.552713678800501e-15; // 2^-48
+        let r = x.round();
+        if (x - r).abs() + x.abs() * TIE_GUARD < 0.5 {
+            return r
+                .to_i128()
+                .filter(|u| is_validate_decimal_precision(*u, precision));
+        }
+        float_to_decimal128(f, precision, scale)
     });
 
-    // ANSI must raise on out-of-range values instead of nulling them. 
`unary_opt` only nulls
-    // non-null inputs that overflow, so a null count beyond the input's 
signals an overflow to
-    // report. This check is O(1); the element-wise rescan runs only on the 
rare error path and
-    // reports the first offending value with Spark's exact error.
+    // ANSI must raise on out-of-range values instead of nulling them, but NaN 
/ infinity
+    // stay null even in ANSI mode: Spark catches the NumberFormatException 
thrown by
+    // `Decimal(double)` and returns null before the ANSI overflow check runs. 
`unary_opt`
+    // only nulls non-null inputs, so a null count beyond the input's signals 
a potential
+    // overflow; the element-wise rescan runs only on that rare path and 
reports the first
+    // finite offending value with Spark's exact error.
     if eval_mode == EvalMode::Ansi && result.null_count() > input.null_count() 
{
         for i in 0..input.len() {
-            if !input.is_null(i) {
+            if !input.is_null(i) && result.is_null(i) {
                 let input_value: f64 = input.value(i).as_();
-                let fits = (input_value * mul)
-                    .round()
-                    .to_i128()
-                    .map(|x| is_validate_decimal_precision(x, precision))
-                    .unwrap_or(false);
-                if !fits {
+                if input_value.is_finite() {
                     return Err(SparkError::NumericValueOutOfRange {
                         value: input_value.to_string(),
                         precision,
@@ -939,6 +961,71 @@ where
     Ok(Arc::new(result.with_precision_and_scale(precision, scale)?))
 }
 
+/// Convert a double to a decimal unscaled value with Spark semantics.
+///
+/// Spark converts through `BigDecimal(Double.toString(d)).setScale(scale, 
HALF_UP)`: it
+/// rounds the shortest decimal string form of the value, not its exact binary 
expansion.
+/// The two disagree for values like 0.5153125 whose binary value 
(0.51531249999...) sits
+/// just below the rounding tie that the string form lands on, so a plain
+/// `(f * 10^scale).round()` produces results that differ from Spark.
+///
+/// Returns `None` for NaN / infinity and for results that do not fit 
`precision`.
+fn float_to_decimal128(f: f64, precision: u8, scale: i8) -> Option<i128> {
+    if !f.is_finite() {
+        return None;
+    }
+
+    // Shortest round-trip decimal form, same digits as Java's Double.toString
+    let mut buf = ryu::Buffer::new();
+    let (mantissa, exp10) = parse_decimal_notation(buf.format_finite(f))?;
+
+    // value = mantissa * 10^exp10, so unscaled = round(mantissa * 10^(exp10 + 
scale))
+    let shift = exp10 + scale as i32;
+    let unscaled = if shift >= 0 {
+        // Overflowing i128 here means the result cannot fit any decimal 
precision
+        mantissa.checked_mul(pow10_i128(shift as u32)?)?
+    } else {
+        match pow10_i128(-shift as u32) {
+            // The mantissa has at most 17 significant digits, so dividing by 
a power of
+            // ten too large for i128 always rounds to zero
+            None => 0,
+            // Divide with HALF_UP rounding (away from zero on a tie, matching 
BigDecimal)
+            Some(div) => div_round_half_up_i128(mantissa, div),
+        }
+    };
+
+    is_validate_decimal_precision(unscaled, precision).then_some(unscaled)
+}
+
+/// Parse ryu's `[-]digits[.digits][e[-]digits]` output into an integer 
mantissa and a
+/// base-10 exponent such that the value equals `mantissa * 10^exp10`.
+///
+/// Returns `None` if the mantissa does not fit an `i128`, which cannot happen 
for ryu
+/// output: a shortest-form double carries at most 17 significant digits.
+fn parse_decimal_notation(s: &str) -> Option<(i128, i32)> {
+    let (digits, exp10) = match s.split_once('e') {
+        Some((digits, exp)) => (digits, exp.parse::<i32>().expect("exponent 
from ryu")),
+        None => (s, 0),
+    };
+    let (digits, negative) = match digits.strip_prefix('-') {
+        Some(unsigned) => (unsigned, true),
+        None => (digits, false),
+    };
+    let (integral, fractional) = match digits.split_once('.') {
+        Some((integral, fractional)) => (integral, fractional),
+        None => (digits, ""),
+    };
+
+    // value = (integral * 10^frac_digits + fractional) * 10^(exp10 - 
frac_digits)
+    let frac_digits = fractional.len() as i32;
+    let mantissa = pow10_i128(frac_digits as u32)
+        .and_then(|p| digits_to_i128(integral.as_bytes())?.checked_mul(p))
+        .and_then(|v| v.checked_add(digits_to_i128(fractional.as_bytes())?))?;
+
+    let mantissa = if negative { -mantissa } else { mantissa };
+    Some((mantissa, exp10 - frac_digits))
+}
+
 pub(crate) fn spark_cast_nonintegral_numeric_to_integral(
     array: &dyn Array,
     eval_mode: EvalMode,
@@ -1325,13 +1412,6 @@ mod tests {
         }
     }
     #[test]
-    // Currently the cast function depending on `f64::powi`, which has 
unspecified precision according to the doc
-    // https://doc.rust-lang.org/std/primitive.f64.html#unspecified-precision.
-    // Miri deliberately apply random floating-point errors to these 
operations to expose bugs
-    // https://github.com/rust-lang/miri/issues/4395.
-    // The random errors may interfere with test cases at rounding edge, so we 
ignore it on miri for now.
-    // Once https://github.com/apache/datafusion-comet/issues/1371 is fixed, 
this should no longer be an issue.
-    #[cfg_attr(miri, ignore)]
     fn test_cast_float_to_decimal() {
         let a: ArrayRef = Arc::new(Float64Array::from(vec![
             Some(42.),
@@ -1350,8 +1430,7 @@ mod tests {
         assert_eq!(b.len(), a.len());
         let casted = b.as_primitive::<Decimal128Type>();
         assert_eq!(casted.value(0), 42000000);
-        // https://github.com/apache/datafusion-comet/issues/1371
-        // assert_eq!(casted.value(1), 515313);
+        assert_eq!(casted.value(1), 515313);
         assert_eq!(casted.value(2), -42424242);
         assert_eq!(casted.value(3), 0);
         assert_eq!(casted.value(4), 0);
@@ -1363,7 +1442,6 @@ mod tests {
     }
 
     #[test]
-    #[cfg_attr(miri, ignore)]
     fn test_cast_float_to_decimal_no_overflow_fast_path() {
         // All values fit precision 10, scale 2, so the vectorized fast path 
is taken and the
         // input null is preserved.
@@ -1384,7 +1462,6 @@ mod tests {
     }
 
     #[test]
-    #[cfg_attr(miri, ignore)]
     fn test_cast_float_to_decimal_ansi_no_overflow() {
         let a: ArrayRef = Arc::new(Float64Array::from(vec![Some(42.0), None, 
Some(-1.5)]));
         let b =
@@ -1396,7 +1473,6 @@ mod tests {
     }
 
     #[test]
-    #[cfg_attr(miri, ignore)]
     fn test_cast_float_to_decimal_ansi_overflow_errors() {
         // Precision overflow: 4242.42 * 10^2 = 424242 does not fit precision 
4. Two overflow
         // rows with distinct string forms pin that the rescan reports the 
*first* offender.
@@ -1419,34 +1495,177 @@ mod tests {
         }
     }
 
+    /// Cast an f64 array to Decimal128(precision, scale), panicking on error
+    fn cast_f64_to_decimal128(
+        values: Vec<Option<f64>>,
+        precision: u8,
+        scale: i8,
+        eval_mode: EvalMode,
+    ) -> Decimal128Array {
+        let a: ArrayRef = Arc::new(Float64Array::from(values));
+        cast_floating_point_to_decimal128::<Float64Type>(&a, precision, scale, 
eval_mode)
+            .unwrap()
+            .as_primitive::<Decimal128Type>()
+            .clone()
+    }
+
     #[test]
+    fn test_cast_float_to_decimal_ansi_nan_and_infinity_are_null() {
+        // Spark converts through Decimal(double) which throws 
NumberFormatException for
+        // NaN/infinity; the cast catches it and produces null before the ANSI 
overflow
+        // check runs, so these are null even in ANSI mode.
+        let d = cast_f64_to_decimal128(
+            vec![
+                Some(1.0),
+                Some(f64::NAN),
+                Some(f64::INFINITY),
+                Some(f64::NEG_INFINITY),
+            ],
+            10,
+            2,
+            EvalMode::Ansi,
+        );
+        assert_eq!(d.value(0), 100);
+        assert!(d.is_null(1));
+        assert!(d.is_null(2));
+        assert!(d.is_null(3));
+    }
+
+    #[test]
+    fn test_parse_decimal_notation_round_trips_ryu_output() {
+        // Every shape ryu can emit: plain integral ("1.0"), plain fractional 
("12.34"),
+        // leading-zero fractional ("0.001234", up to 21 fractional digits), 
single-digit
+        // exponent form ("1e30") and full exponent form ("1.234e33"). The 
extremes pin
+        // down that the mantissa always fits an i128, so the `None` arm stays 
unreachable.
+        let mut buf = ryu::Buffer::new();
+        for v in [
+            0.0,
+            1.0,
+            -1.0,
+            12.34,
+            -0.001234,
+            0.5153125,
+            1e16,
+            1e17,
+            1.5e10,
+            1e30,
+            1.2345678901234567e-5,
+            1.2345678901234567e300,
+            f64::MAX,
+            f64::MIN,
+            f64::MIN_POSITIVE,
+            f64::from_bits(1), // smallest subnormal
+        ] {
+            let formatted = buf.format_finite(v);
+            let (mantissa, exp10) =
+                parse_decimal_notation(formatted).expect("ryu mantissa fits 
i128");
+            let reconstructed: f64 = 
format!("{mantissa}e{exp10}").parse().unwrap();
+            assert_eq!(reconstructed, v, "{formatted} -> {mantissa}e{exp10}");
+        }
+    }
+
+    #[test]
+    fn test_cast_float_to_decimal_spark_rounding_parity() {
+        // Spark rounds the shortest decimal string form of the double 
(Double.toString ->
+        // BigDecimal.setScale(HALF_UP)), not the binary value. These values 
sit exactly on
+        // a rounding tie in string form while the binary value is just below 
it, so binary
+        // rounding gives a different (wrong) result.
+        let d = cast_f64_to_decimal128(
+            vec![Some(0.5153125), Some(-0.5153125), Some(0.5203125)],
+            8,
+            6,
+            EvalMode::Legacy,
+        );
+        assert_eq!(d.value(0), 515313);
+        assert_eq!(d.value(1), -515313);
+        assert_eq!(d.value(2), 520313);
+
+        // HALF_UP rounds away from zero for negative values too
+        let d = cast_f64_to_decimal128(vec![Some(1.005), Some(-1.005)], 10, 2, 
EvalMode::Legacy);
+        assert_eq!(d.value(0), 101);
+        assert_eq!(d.value(1), -101);
+
+        // values whose shortest form uses exponent notation
+        let d = cast_f64_to_decimal128(vec![Some(1.5e10)], 20, 2, 
EvalMode::Legacy);
+        assert_eq!(d.value(0), 1_500_000_000_000);
+
+        let d = cast_f64_to_decimal128(vec![Some(1e30)], 10, 2, 
EvalMode::Legacy);
+        assert!(d.is_null(0));
+    }
+
+    #[test]
+    fn test_cast_float32_to_decimal_widens_to_double_first() {
+        // Spark widens float to double before converting to string, so 
1.005f32 becomes
+        // 1.0049999952316284 and rounds down; using the shortest f32 form 
("1.005") would
+        // incorrectly round up.
+        let a: ArrayRef = Arc::new(Float32Array::from(vec![Some(1.005f32)]));
+        let b =
+            cast_floating_point_to_decimal128::<Float32Type>(&a, 10, 2, 
EvalMode::Legacy).unwrap();
+        let d = b.as_primitive::<Decimal128Type>();
+        assert_eq!(d.value(0), 100);
+
+        // 0.5153125f32 widens to 0.5153124928474426 which rounds down, unlike 
the f64 case
+        let a: ArrayRef = 
Arc::new(Float32Array::from(vec![Some(0.5153125f32)]));
+        let b =
+            cast_floating_point_to_decimal128::<Float32Type>(&a, 8, 6, 
EvalMode::Legacy).unwrap();
+        let d = b.as_primitive::<Decimal128Type>();
+        assert_eq!(d.value(0), 515312);
+    }
+
+    #[test]
+    // Not a correctness concern under miri, just far too slow: 240k values 
through the
+    // interpreter. The fixed-value tests above cover both paths under miri.
     #[cfg_attr(miri, ignore)]
-    fn test_cast_float_to_decimal_ansi_nan_errors() {
-        // NaN / infinity have no in-range integer form: `to_i128()` returns 
`None`, so the
-        // closure nulls the slot and the rescan must recompute `fits == 
false` and raise.
-        // This is a distinct path from finite precision overflow — assert 
both here to lock
-        // the reported value string.
-        let a: ArrayRef = Arc::new(Float64Array::from(vec![Some(1.0), 
Some(f64::NAN)]));
-        match cast_floating_point_to_decimal128::<Float64Type>(&a, 10, 2, 
EvalMode::Ansi) {
+    fn test_cast_float_to_decimal_fast_path_matches_exact_path() {
+        use rand::rngs::StdRng;
+        use rand::{RngExt, SeedableRng};
+        let mut rng = StdRng::seed_from_u64(42);
+        let mut values: Vec<f64> = Vec::new();
+        // random bit patterns cover the full range including subnormals, NaN 
and infinity
+        for _ in 0..20_000 {
+            values.push(f64::from_bits(rng.random::<u64>()));
+        }
+        // decimal-looking values, which land on or near rounding ties far 
more often
+        for _ in 0..20_000 {
+            let unscaled = rng.random_range(-10_000_000_i64..10_000_000);
+            let scale = rng.random_range(0_i32..8);
+            values.push(unscaled as f64 / 10_f64.powi(scale));
+        }
+        for (precision, scale) in [(38_u8, 18_i8), (18, 6), (10, 2), (8, 6), 
(4, 2), (38, 0)] {
+            let d = cast_f64_to_decimal128(
+                values.iter().copied().map(Some).collect(),
+                precision,
+                scale,
+                EvalMode::Legacy,
+            );
+            for (i, &v) in values.iter().enumerate() {
+                let expected = float_to_decimal128(v, precision, scale);
+                let actual = (!d.is_null(i)).then(|| d.value(i));
+                assert_eq!(actual, expected, "value {v:?} at 
({precision},{scale})");
+            }
+        }
+    }
+
+    #[test]
+    fn test_cast_float_to_decimal_round_up_overflow() {
+        // 99.995 rounds up to 100.00 in string form, which no longer fits 
precision 4.
+        // Binary rounding of 99.99499999999999886... would incorrectly keep 
99.99.
+        let d = cast_f64_to_decimal128(vec![Some(99.995)], 4, 2, 
EvalMode::Legacy);
+        assert!(d.is_null(0));
+
+        let a: ArrayRef = Arc::new(Float64Array::from(vec![Some(99.995)]));
+        match cast_floating_point_to_decimal128::<Float64Type>(&a, 4, 2, 
EvalMode::Ansi) {
             Err(SparkError::NumericValueOutOfRange {
                 value,
                 precision,
                 scale,
             }) => {
-                assert_eq!(value, "NaN");
-                assert_eq!(precision, 10);
+                assert_eq!(value, "99.995");
+                assert_eq!(precision, 4);
                 assert_eq!(scale, 2);
             }
             other => panic!("expected NumericValueOutOfRange, got {other:?}"),
         }
-
-        let a: ArrayRef = 
Arc::new(Float64Array::from(vec![Some(f64::INFINITY)]));
-        match cast_floating_point_to_decimal128::<Float64Type>(&a, 10, 2, 
EvalMode::Ansi) {
-            Err(SparkError::NumericValueOutOfRange { value, .. }) => {
-                assert_eq!(value, "inf");
-            }
-            other => panic!("expected NumericValueOutOfRange, got {other:?}"),
-        }
     }
 
     #[test]
diff --git a/native/spark-expr/src/conversion_funcs/string.rs 
b/native/spark-expr/src/conversion_funcs/string.rs
index 091c6544d5..46495ed981 100644
--- a/native/spark-expr/src/conversion_funcs/string.rs
+++ b/native/spark-expr/src/conversion_funcs/string.rs
@@ -507,16 +507,33 @@ const POW10_I128: [i128; 39] = {
 
 /// `10^exp`, or `None` when the exponent overflows an `i128` (exp >= 39).
 #[inline]
-fn pow10_i128(exp: u32) -> Option<i128> {
+pub(crate) fn pow10_i128(exp: u32) -> Option<i128> {
     POW10_I128.get(exp as usize).copied()
 }
 
+/// Divide by a power of ten with HALF_UP rounding, matching 
`BigDecimal.setScale`: a tie
+/// rounds away from zero.
+///
+/// `divisor` must be `10^n` for `n >= 1`, so `divisor / 2` is exact and a 
zero remainder
+/// can never be mistaken for a tie.
+#[inline]
+pub(crate) fn div_round_half_up_i128(numerator: i128, divisor: i128) -> i128 {
+    debug_assert!(divisor >= 10);
+    let quotient = numerator / divisor;
+    let remainder = numerator % divisor;
+    if remainder.abs() >= divisor / 2 {
+        quotient + numerator.signum()
+    } else {
+        quotient
+    }
+}
+
 /// Accumulate an ASCII-digit slice into an `i128`, returning `None` on 
overflow.
 ///
 /// The first 38 digits always fit (`i128::MAX` is ~1.7e38), so only the 
digits past
 /// them need the per-digit overflow checks.
 #[inline]
-fn digits_to_i128(digits: &[u8]) -> Option<i128> {
+pub(crate) fn digits_to_i128(digits: &[u8]) -> Option<i128> {
     let (head, tail) = digits.split_at(digits.len().min(38));
     let mut value: i128 = 0;
     for &d in head {
@@ -616,28 +633,10 @@ fn parse_string_to_decimal(input_str: &str, precision: 
u8, scale: i8) -> SparkRe
             return Ok(Some(0));
         }
 
-        // Bounded above, so pow10_i128 always returns Some.
+        // Bounded above, so pow10_i128 always returns Some. The adjustment is 
at least 1
+        // here, so the divisor is a power of ten no smaller than 10.
         let divisor = pow10_i128(abs_scale_adjustment).unwrap();
-        let quotient_opt = mantissa.checked_div(divisor);
-        // Check if divisor is 0
-        if quotient_opt.is_none() {
-            return Ok(None);
-        }
-        let quotient = quotient_opt.unwrap();
-        let remainder = mantissa % divisor;
-
-        // Round half up: if abs(remainder) >= divisor/2, round away from zero
-        let half_divisor = divisor / 2;
-        let rounded = if remainder.abs() >= half_divisor {
-            if mantissa >= 0 {
-                quotient + 1
-            } else {
-                quotient - 1
-            }
-        } else {
-            quotient
-        };
-        Some(rounded)
+        Some(div_round_half_up_i128(mantissa, divisor))
     };
 
     match scaled_value {
diff --git a/spark/src/main/scala/org/apache/comet/expressions/CometCast.scala 
b/spark/src/main/scala/org/apache/comet/expressions/CometCast.scala
index eba1e4b152..78abf54c8d 100644
--- a/spark/src/main/scala/org/apache/comet/expressions/CometCast.scala
+++ b/spark/src/main/scala/org/apache/comet/expressions/CometCast.scala
@@ -55,6 +55,15 @@ object CometCast
       .getConfString("spark.sql.legacy.castComplexTypesToString.enabled", 
"false")
       .toBoolean
 
+  // Spark rounds the shortest decimal string form of the value 
(`Double.toString`) rather
+  // than its binary expansion, and Comet's native cast reproduces that. 
`Double.toString`
+  // only emits the shortest round-trip form on JDK 19 and later 
(JDK-4511638); older JDKs
+  // can emit an extra digit, so a value whose shortest form lands exactly on 
a rounding
+  // tie at the target scale can round differently there.
+  private val floatToDecimalJdkNote: String =
+    "Rounding matches Spark on JDK 19 and later. On older JDKs, Spark's own 
result may " +
+      "differ for values whose shortest decimal form falls exactly on a 
rounding tie"
+
   def supportedTypes: Seq[DataType] =
     Seq(
       DataTypes.BooleanType,
@@ -425,8 +434,7 @@ object CometCast
         DataTypes.IntegerType | DataTypes.LongType | DataTypes.TimestampType =>
       Compatible()
     case _: DecimalType =>
-      // https://github.com/apache/datafusion-comet/issues/1371
-      Incompatible(Some("There can be rounding differences"))
+      Compatible(Some(floatToDecimalJdkNote))
     case _ =>
       unsupported(DataTypes.FloatType, toType)
   }
@@ -436,8 +444,7 @@ object CometCast
         DataTypes.IntegerType | DataTypes.LongType | DataTypes.TimestampType =>
       Compatible()
     case _: DecimalType =>
-      // https://github.com/apache/datafusion-comet/issues/1371
-      Incompatible(Some("There can be rounding differences"))
+      Compatible(Some(floatToDecimalJdkNote))
     case _ => unsupported(DataTypes.DoubleType, toType)
   }
 
diff --git 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4-spark3_5/q44/extended.txt
 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4-spark3_5/q44/extended.txt
index eb162b967a..236d51688a 100644
--- 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4-spark3_5/q44/extended.txt
+++ 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4-spark3_5/q44/extended.txt
@@ -19,13 +19,13 @@ TakeOrderedAndProject
       :     :     :                                +- CometFilter
       :     :     :                                   :  +- Subquery
       :     :     :                                   :     +- 
CometColumnarToRow
-      :     :     :                                   :        +-  
CometHashAggregate [COMET-INFO: A native implementation of Cast is available. 
Set spark.comet.expression.Cast.allowIncompatible=true to enable it. For more 
information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+      :     :     :                                   :        +- 
CometHashAggregate
       :     :     :                                   :           +- 
CometExchange
       :     :     :                                   :              +- 
CometHashAggregate
       :     :     :                                   :                 +- 
CometProject
       :     :     :                                   :                    +- 
CometFilter
       :     :     :                                   :                       
+- CometNativeScan parquet spark_catalog.default.store_sales
-      :     :     :                                   +-  CometHashAggregate 
[COMET-INFO: A native implementation of Cast is available. Set 
spark.comet.expression.Cast.allowIncompatible=true to enable it. For more 
information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+      :     :     :                                   +- CometHashAggregate
       :     :     :                                      +- CometExchange
       :     :     :                                         +- 
CometHashAggregate
       :     :     :                                            +- CometProject
@@ -44,7 +44,7 @@ TakeOrderedAndProject
       :     :                                   +- CometSort
       :     :                                      +- CometFilter
       :     :                                         :  +- ReusedSubquery
-      :     :                                         +-  CometHashAggregate 
[COMET-INFO: A native implementation of Cast is available. Set 
spark.comet.expression.Cast.allowIncompatible=true to enable it. For more 
information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+      :     :                                         +- CometHashAggregate
       :     :                                            +- CometExchange
       :     :                                               +- 
CometHashAggregate
       :     :                                                  +- CometProject
diff --git 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4-spark4_0/q44/extended.txt
 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4-spark4_0/q44/extended.txt
index 48071ad123..6595583fa5 100644
--- 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4-spark4_0/q44/extended.txt
+++ 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4-spark4_0/q44/extended.txt
@@ -21,13 +21,13 @@ CometColumnarToRow
          :     :     :                                   +- CometFilter
          :     :     :                                      :  +- Subquery
          :     :     :                                      :     +- 
CometColumnarToRow
-         :     :     :                                      :        +-  
CometHashAggregate [COMET-INFO: A native implementation of Cast is available. 
Set spark.comet.expression.Cast.allowIncompatible=true to enable it. For more 
information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+         :     :     :                                      :        +- 
CometHashAggregate
          :     :     :                                      :           +- 
CometExchange
          :     :     :                                      :              +- 
CometHashAggregate
          :     :     :                                      :                 
+- CometProject
          :     :     :                                      :                  
  +- CometFilter
          :     :     :                                      :                  
     +- CometNativeScan parquet spark_catalog.default.store_sales
-         :     :     :                                      +-  
CometHashAggregate [COMET-INFO: A native implementation of Cast is available. 
Set spark.comet.expression.Cast.allowIncompatible=true to enable it. For more 
information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+         :     :     :                                      +- 
CometHashAggregate
          :     :     :                                         +- CometExchange
          :     :     :                                            +- 
CometHashAggregate
          :     :     :                                               +- 
CometProject
@@ -47,7 +47,7 @@ CometColumnarToRow
          :     :                                      +- CometSort
          :     :                                         +- CometFilter
          :     :                                            :  +- 
ReusedSubquery
-         :     :                                            +-  
CometHashAggregate [COMET-INFO: A native implementation of Cast is available. 
Set spark.comet.expression.Cast.allowIncompatible=true to enable it. For more 
information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+         :     :                                            +- 
CometHashAggregate
          :     :                                               +- CometExchange
          :     :                                                  +- 
CometHashAggregate
          :     :                                                     +- 
CometProject
diff --git 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4-spark4_0/q6/extended.txt
 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4-spark4_0/q6/extended.txt
index a737cf153d..d452f76572 100644
--- 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4-spark4_0/q6/extended.txt
+++ 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4-spark4_0/q6/extended.txt
@@ -55,7 +55,7 @@ CometColumnarToRow
                               :  +- CometNativeScan parquet 
spark_catalog.default.item
                               +- CometBroadcastExchange
                                  +- CometFilter
-                                    +-  CometHashAggregate [COMET-INFO: A 
native implementation of Cast is available. Set 
spark.comet.expression.Cast.allowIncompatible=true to enable it. For more 
information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+                                    +- CometHashAggregate
                                        +- CometExchange
                                           +- CometHashAggregate
                                              +- CometProject
diff --git 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q13/extended.txt
 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q13/extended.txt
index 443dd08d35..80dfee9629 100644
--- 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q13/extended.txt
+++ 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q13/extended.txt
@@ -1,5 +1,5 @@
 CometColumnarToRow
-+-  CometHashAggregate [COMET-INFO: A native implementation of Cast is 
available. Set spark.comet.expression.Cast.allowIncompatible=true to enable it. 
For more information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
++- CometHashAggregate
    +- CometExchange
       +- CometHashAggregate
          +- CometProject
diff --git 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q26/extended.txt
 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q26/extended.txt
index 5c41b12c08..764e2b9c71 100644
--- 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q26/extended.txt
+++ 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q26/extended.txt
@@ -1,6 +1,6 @@
 CometColumnarToRow
 +- CometTakeOrderedAndProject
-   +-  CometHashAggregate [COMET-INFO: A native implementation of Cast is 
available. Set spark.comet.expression.Cast.allowIncompatible=true to enable it. 
For more information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+   +- CometHashAggregate
       +- CometExchange
          +- CometHashAggregate
             +- CometProject
diff --git 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q27/extended.txt
 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q27/extended.txt
index c6dfd3e7ef..7f2df4754b 100644
--- 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q27/extended.txt
+++ 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q27/extended.txt
@@ -1,6 +1,6 @@
 CometColumnarToRow
 +- CometTakeOrderedAndProject
-   +-  CometHashAggregate [COMET-INFO: A native implementation of Cast is 
available. Set spark.comet.expression.Cast.allowIncompatible=true to enable it. 
For more information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+   +- CometHashAggregate
       +- CometExchange
          +- CometHashAggregate
             +- CometExpand
diff --git 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q28/extended.txt
 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q28/extended.txt
index bb5b1f95a2..b63d8a2b9d 100644
--- 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q28/extended.txt
+++ 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q28/extended.txt
@@ -4,7 +4,7 @@ CometColumnarToRow
    :  :- CometBroadcastNestedLoopJoin
    :  :  :- CometBroadcastNestedLoopJoin
    :  :  :  :- CometBroadcastNestedLoopJoin
-   :  :  :  :  :-  CometHashAggregate [COMET-INFO: A native implementation of 
Cast is available. Set spark.comet.expression.Cast.allowIncompatible=true to 
enable it. For more information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+   :  :  :  :  :- CometHashAggregate
    :  :  :  :  :  +- CometExchange
    :  :  :  :  :     +- CometHashAggregate
    :  :  :  :  :        +- CometHashAggregate
@@ -14,7 +14,7 @@ CometColumnarToRow
    :  :  :  :  :                    +- CometFilter
    :  :  :  :  :                       +- CometNativeScan parquet 
spark_catalog.default.store_sales
    :  :  :  :  +- CometBroadcastExchange
-   :  :  :  :     +-  CometHashAggregate [COMET-INFO: A native implementation 
of Cast is available. Set spark.comet.expression.Cast.allowIncompatible=true to 
enable it. For more information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+   :  :  :  :     +- CometHashAggregate
    :  :  :  :        +- CometExchange
    :  :  :  :           +- CometHashAggregate
    :  :  :  :              +- CometHashAggregate
@@ -24,7 +24,7 @@ CometColumnarToRow
    :  :  :  :                          +- CometFilter
    :  :  :  :                             +- CometNativeScan parquet 
spark_catalog.default.store_sales
    :  :  :  +- CometBroadcastExchange
-   :  :  :     +-  CometHashAggregate [COMET-INFO: A native implementation of 
Cast is available. Set spark.comet.expression.Cast.allowIncompatible=true to 
enable it. For more information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+   :  :  :     +- CometHashAggregate
    :  :  :        +- CometExchange
    :  :  :           +- CometHashAggregate
    :  :  :              +- CometHashAggregate
@@ -34,7 +34,7 @@ CometColumnarToRow
    :  :  :                          +- CometFilter
    :  :  :                             +- CometNativeScan parquet 
spark_catalog.default.store_sales
    :  :  +- CometBroadcastExchange
-   :  :     +-  CometHashAggregate [COMET-INFO: A native implementation of 
Cast is available. Set spark.comet.expression.Cast.allowIncompatible=true to 
enable it. For more information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+   :  :     +- CometHashAggregate
    :  :        +- CometExchange
    :  :           +- CometHashAggregate
    :  :              +- CometHashAggregate
@@ -44,7 +44,7 @@ CometColumnarToRow
    :  :                          +- CometFilter
    :  :                             +- CometNativeScan parquet 
spark_catalog.default.store_sales
    :  +- CometBroadcastExchange
-   :     +-  CometHashAggregate [COMET-INFO: A native implementation of Cast 
is available. Set spark.comet.expression.Cast.allowIncompatible=true to enable 
it. For more information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+   :     +- CometHashAggregate
    :        +- CometExchange
    :           +- CometHashAggregate
    :              +- CometHashAggregate
@@ -54,7 +54,7 @@ CometColumnarToRow
    :                          +- CometFilter
    :                             +- CometNativeScan parquet 
spark_catalog.default.store_sales
    +- CometBroadcastExchange
-      +-  CometHashAggregate [COMET-INFO: A native implementation of Cast is 
available. Set spark.comet.expression.Cast.allowIncompatible=true to enable it. 
For more information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+      +- CometHashAggregate
          +- CometExchange
             +- CometHashAggregate
                +- CometHashAggregate
diff --git 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q32/extended.txt
 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q32/extended.txt
index e4e3cb9529..88b18a4e60 100644
--- 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q32/extended.txt
+++ 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q32/extended.txt
@@ -21,7 +21,7 @@ CometColumnarToRow
                :     :              +- CometNativeScan parquet 
spark_catalog.default.item
                :     +- CometBroadcastExchange
                :        +- CometFilter
-               :           +-  CometHashAggregate [COMET-INFO: A native 
implementation of Cast is available. Set 
spark.comet.expression.Cast.allowIncompatible=true to enable it. For more 
information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+               :           +- CometHashAggregate
                :              +- CometExchange
                :                 +- CometHashAggregate
                :                    +- CometProject
diff --git 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q44/extended.txt
 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q44/extended.txt
index 9d32a5f0f1..d606397281 100644
--- 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q44/extended.txt
+++ 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q44/extended.txt
@@ -15,13 +15,13 @@ CometColumnarToRow
          :     :     :                 +- CometFilter
          :     :     :                    :  +- Subquery
          :     :     :                    :     +- CometColumnarToRow
-         :     :     :                    :        +-  CometHashAggregate 
[COMET-INFO: A native implementation of Cast is available. Set 
spark.comet.expression.Cast.allowIncompatible=true to enable it. For more 
information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+         :     :     :                    :        +- CometHashAggregate
          :     :     :                    :           +- CometExchange
          :     :     :                    :              +- CometHashAggregate
          :     :     :                    :                 +- CometProject
          :     :     :                    :                    +- CometFilter
          :     :     :                    :                       +- 
CometNativeScan parquet spark_catalog.default.store_sales
-         :     :     :                    +-  CometHashAggregate [COMET-INFO: 
A native implementation of Cast is available. Set 
spark.comet.expression.Cast.allowIncompatible=true to enable it. For more 
information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+         :     :     :                    +- CometHashAggregate
          :     :     :                       +- CometExchange
          :     :     :                          +- CometHashAggregate
          :     :     :                             +- CometProject
@@ -36,13 +36,13 @@ CometColumnarToRow
          :     :                       +- CometFilter
          :     :                          :  +- Subquery
          :     :                          :     +- CometColumnarToRow
-         :     :                          :        +-  CometHashAggregate 
[COMET-INFO: A native implementation of Cast is available. Set 
spark.comet.expression.Cast.allowIncompatible=true to enable it. For more 
information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+         :     :                          :        +- CometHashAggregate
          :     :                          :           +- CometExchange
          :     :                          :              +- CometHashAggregate
          :     :                          :                 +- CometProject
          :     :                          :                    +- CometFilter
          :     :                          :                       +- 
CometNativeScan parquet spark_catalog.default.store_sales
-         :     :                          +-  CometHashAggregate [COMET-INFO: 
A native implementation of Cast is available. Set 
spark.comet.expression.Cast.allowIncompatible=true to enable it. For more 
information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+         :     :                          +- CometHashAggregate
          :     :                             +- CometExchange
          :     :                                +- CometHashAggregate
          :     :                                   +- CometProject
diff --git 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q6/extended.txt
 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q6/extended.txt
index e847952604..c9c8cfb975 100644
--- 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q6/extended.txt
+++ 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q6/extended.txt
@@ -53,7 +53,7 @@ CometColumnarToRow
                               :  +- CometNativeScan parquet 
spark_catalog.default.item
                               +- CometBroadcastExchange
                                  +- CometFilter
-                                    +-  CometHashAggregate [COMET-INFO: A 
native implementation of Cast is available. Set 
spark.comet.expression.Cast.allowIncompatible=true to enable it. For more 
information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+                                    +- CometHashAggregate
                                        +- CometExchange
                                           +- CometHashAggregate
                                              +- CometProject
diff --git 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q7/extended.txt
 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q7/extended.txt
index 7b60b3e7c7..ff85af4ff4 100644
--- 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q7/extended.txt
+++ 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q7/extended.txt
@@ -1,6 +1,6 @@
 CometColumnarToRow
 +- CometTakeOrderedAndProject
-   +-  CometHashAggregate [COMET-INFO: A native implementation of Cast is 
available. Set spark.comet.expression.Cast.allowIncompatible=true to enable it. 
For more information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+   +- CometHashAggregate
       +- CometExchange
          +- CometHashAggregate
             +- CometProject
diff --git 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q85/extended.txt
 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q85/extended.txt
index 5b3d8e7c4d..7ea084adf4 100644
--- 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q85/extended.txt
+++ 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q85/extended.txt
@@ -1,6 +1,6 @@
 CometColumnarToRow
 +- CometTakeOrderedAndProject
-   +-  CometHashAggregate [COMET-INFO: A native implementation of Cast is 
available. Set spark.comet.expression.Cast.allowIncompatible=true to enable it. 
For more information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+   +- CometHashAggregate
       +- CometExchange
          +- CometHashAggregate
             +- CometProject
diff --git 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q9/extended.txt
 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q9/extended.txt
index 82ae15542c..53ba3252cf 100644
--- 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q9/extended.txt
+++ 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q9/extended.txt
@@ -2,7 +2,7 @@
 :  :- Subquery
 :  :  +- CometColumnarToRow
 :  :     +- CometProject
-:  :        +-  CometHashAggregate [COMET-INFO: A native implementation of 
Cast is available. Set spark.comet.expression.Cast.allowIncompatible=true to 
enable it. For more information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+:  :        +- CometHashAggregate
 :  :           +- CometExchange
 :  :              +- CometHashAggregate
 :  :                 +- CometProject
@@ -13,7 +13,7 @@
 :  :- Subquery
 :  :  +- CometColumnarToRow
 :  :     +- CometProject
-:  :        +-  CometHashAggregate [COMET-INFO: A native implementation of 
Cast is available. Set spark.comet.expression.Cast.allowIncompatible=true to 
enable it. For more information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+:  :        +- CometHashAggregate
 :  :           +- CometExchange
 :  :              +- CometHashAggregate
 :  :                 +- CometProject
@@ -24,7 +24,7 @@
 :  :- Subquery
 :  :  +- CometColumnarToRow
 :  :     +- CometProject
-:  :        +-  CometHashAggregate [COMET-INFO: A native implementation of 
Cast is available. Set spark.comet.expression.Cast.allowIncompatible=true to 
enable it. For more information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+:  :        +- CometHashAggregate
 :  :           +- CometExchange
 :  :              +- CometHashAggregate
 :  :                 +- CometProject
@@ -35,7 +35,7 @@
 :  :- Subquery
 :  :  +- CometColumnarToRow
 :  :     +- CometProject
-:  :        +-  CometHashAggregate [COMET-INFO: A native implementation of 
Cast is available. Set spark.comet.expression.Cast.allowIncompatible=true to 
enable it. For more information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+:  :        +- CometHashAggregate
 :  :           +- CometExchange
 :  :              +- CometHashAggregate
 :  :                 +- CometProject
@@ -46,7 +46,7 @@
 :  :- Subquery
 :  :  +- CometColumnarToRow
 :  :     +- CometProject
-:  :        +-  CometHashAggregate [COMET-INFO: A native implementation of 
Cast is available. Set spark.comet.expression.Cast.allowIncompatible=true to 
enable it. For more information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+:  :        +- CometHashAggregate
 :  :           +- CometExchange
 :  :              +- CometHashAggregate
 :  :                 +- CometProject
diff --git 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q92/extended.txt
 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q92/extended.txt
index b00960a1b9..eb03ca3a22 100644
--- 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q92/extended.txt
+++ 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q92/extended.txt
@@ -21,7 +21,7 @@ CometColumnarToRow
                :     :              +- CometNativeScan parquet 
spark_catalog.default.item
                :     +- CometBroadcastExchange
                :        +- CometFilter
-               :           +-  CometHashAggregate [COMET-INFO: A native 
implementation of Cast is available. Set 
spark.comet.expression.Cast.allowIncompatible=true to enable it. For more 
information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+               :           +- CometHashAggregate
                :              +- CometExchange
                :                 +- CometHashAggregate
                :                    +- CometProject
diff --git 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v2_7-spark4_0/q6/extended.txt
 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v2_7-spark4_0/q6/extended.txt
index a737cf153d..d452f76572 100644
--- 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v2_7-spark4_0/q6/extended.txt
+++ 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v2_7-spark4_0/q6/extended.txt
@@ -55,7 +55,7 @@ CometColumnarToRow
                               :  +- CometNativeScan parquet 
spark_catalog.default.item
                               +- CometBroadcastExchange
                                  +- CometFilter
-                                    +-  CometHashAggregate [COMET-INFO: A 
native implementation of Cast is available. Set 
spark.comet.expression.Cast.allowIncompatible=true to enable it. For more 
information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+                                    +- CometHashAggregate
                                        +- CometExchange
                                           +- CometHashAggregate
                                              +- CometProject
diff --git 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v2_7/q27a/extended.txt
 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v2_7/q27a/extended.txt
index 97c22e816f..05101798b2 100644
--- 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v2_7/q27a/extended.txt
+++ 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v2_7/q27a/extended.txt
@@ -1,7 +1,7 @@
 CometColumnarToRow
 +- CometTakeOrderedAndProject
    +- CometUnion
-      :-  CometHashAggregate [COMET-INFO: A native implementation of Cast is 
available. Set spark.comet.expression.Cast.allowIncompatible=true to enable it. 
For more information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+      :- CometHashAggregate
       :  +- CometExchange
       :     +- CometHashAggregate
       :        +- CometProject
@@ -35,7 +35,7 @@ CometColumnarToRow
       :                 +- CometProject
       :                    +- CometFilter
       :                       +- CometNativeScan parquet 
spark_catalog.default.item
-      :-  CometHashAggregate [COMET-INFO: A native implementation of Cast is 
available. Set spark.comet.expression.Cast.allowIncompatible=true to enable it. 
For more information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+      :- CometHashAggregate
       :  +- CometExchange
       :     +- CometHashAggregate
       :        +- CometProject
@@ -65,7 +65,7 @@ CometColumnarToRow
       :                 +- CometProject
       :                    +- CometFilter
       :                       +- CometNativeScan parquet 
spark_catalog.default.item
-      +-  CometHashAggregate [COMET-INFO: A native implementation of Cast is 
available. Set spark.comet.expression.Cast.allowIncompatible=true to enable it. 
For more information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+      +- CometHashAggregate
          +- CometExchange
             +- CometHashAggregate
                +- CometProject
diff --git 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v2_7/q6/extended.txt
 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v2_7/q6/extended.txt
index e847952604..c9c8cfb975 100644
--- 
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v2_7/q6/extended.txt
+++ 
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v2_7/q6/extended.txt
@@ -53,7 +53,7 @@ CometColumnarToRow
                               :  +- CometNativeScan parquet 
spark_catalog.default.item
                               +- CometBroadcastExchange
                                  +- CometFilter
-                                    +-  CometHashAggregate [COMET-INFO: A 
native implementation of Cast is available. Set 
spark.comet.expression.Cast.allowIncompatible=true to enable it. For more 
information, refer to the Comet Compatibility Guide 
(https://datafusion.apache.org/comet/user-guide/latest/compatibility/index.html)]
+                                    +- CometHashAggregate
                                        +- CometExchange
                                           +- CometHashAggregate
                                              +- CometProject
diff --git a/spark/src/test/scala/org/apache/comet/CometCastSuite.scala 
b/spark/src/test/scala/org/apache/comet/CometCastSuite.scala
index 4bcc918516..6e5b03ee35 100644
--- a/spark/src/test/scala/org/apache/comet/CometCastSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/CometCastSuite.scala
@@ -501,17 +501,29 @@ class CometCastSuite extends CometTestBase with 
AdaptiveSparkPlanHelper {
     castTest(generateFloats(), DataTypes.DoubleType)
   }
 
-  ignore("cast FloatType to DecimalType(10,2)") {
-    // // https://github.com/apache/datafusion-comet/issues/1371
+  test("cast FloatType to DecimalType(10,2)") {
     castTest(generateFloats(), DataTypes.createDecimalType(10, 2))
   }
 
-  test("cast FloatType to DecimalType(10,2) - allow incompat") {
-    withSQLConf(CometConf.getExprAllowIncompatConfigKey(classOf[Cast]) -> 
"true") {
-      castTest(generateFloats(), DataTypes.createDecimalType(10, 2))
+  // Values whose shortest decimal form sits exactly on a rounding tie while 
the binary
+  // value is just below it, so rounding the binary value disagrees with 
Spark's
+  // Double.toString-based rounding 
(https://github.com/apache/datafusion-comet/issues/1371)
+  private val decimalRoundingEdgeCases =
+    Seq(0.5153125d, -0.5153125d, 0.5203125d, 1.005d, -1.005d, 99.995d, 
-99.995d, 0.999995d)
+
+  private def castRoundingEdgeCasesTest(input: DataFrame): Unit = {
+    Seq(
+      DataTypes.createDecimalType(10, 2),
+      DataTypes.createDecimalType(8, 6),
+      DataTypes.createDecimalType(4, 2)).foreach { toType =>
+      castTest(input, toType)
     }
   }
 
+  test("cast FloatType to DecimalType - rounding edge cases") {
+    
castRoundingEdgeCasesTest(withNulls(decimalRoundingEdgeCases.map(_.toFloat)).toDF("a"))
+  }
+
   test("cast FloatType to StringType") {
     // https://github.com/apache/datafusion-comet/issues/312
     val r = new Random(0)
@@ -566,15 +578,12 @@ class CometCastSuite extends CometTestBase with 
AdaptiveSparkPlanHelper {
     castTest(generateDoubles(), DataTypes.FloatType)
   }
 
-  ignore("cast DoubleType to DecimalType(10,2)") {
-    // https://github.com/apache/datafusion-comet/issues/1371
+  test("cast DoubleType to DecimalType(10,2)") {
     castTest(generateDoubles(), DataTypes.createDecimalType(10, 2))
   }
 
-  test("cast DoubleType to DecimalType(10,2) - allow incompat") {
-    withSQLConf(CometConf.getExprAllowIncompatConfigKey(classOf[Cast]) -> 
"true") {
-      castTest(generateDoubles(), DataTypes.createDecimalType(10, 2))
-    }
+  test("cast DoubleType to DecimalType - rounding edge cases") {
+    castRoundingEdgeCasesTest(withNulls(decimalRoundingEdgeCases).toDF("a"))
   }
 
   test("cast DoubleType to StringType") {
@@ -1807,16 +1816,25 @@ class CometCastSuite extends CometTestBase with 
AdaptiveSparkPlanHelper {
   }
 
   test("cast MapType propagates Incompatible from inner value cast") {
-    // Float → Decimal is Incompatible due to rounding (see canCastFromFloat).
+    // Negative-scale Decimal → String is Incompatible when
+    // spark.sql.legacy.allowNegativeScaleOfDecimal is disabled (see 
canCastToString).
     // The Map arm must propagate that Incompatible up rather than silently
     // marking the whole Map → Map cast Compatible.
-    assert(
-      CometCast.isSupported(
-        MapType(IntegerType, FloatType),
-        MapType(IntegerType, DecimalType(10, 2)),
-        None,
-        CometEvalMode.LEGACY) ==
-        Incompatible(Some("There can be rounding differences")))
+    // Note: DecimalType(7, -2) must be constructed while the config is 
enabled, because
+    // the constructor itself checks the config and throws if negative scale 
is disallowed.
+    var negScaleType: DecimalType = null
+    withSQLConf("spark.sql.legacy.allowNegativeScaleOfDecimal" -> "true") {
+      negScaleType = DecimalType(7, -2)
+    }
+    withSQLConf("spark.sql.legacy.allowNegativeScaleOfDecimal" -> "false") {
+      assert(
+        CometCast.isSupported(
+          MapType(IntegerType, negScaleType),
+          MapType(IntegerType, StringType),
+          None,
+          CometEvalMode.LEGACY) ==
+          Incompatible(Some(CometCast.negativeScaleDecimalToStringReason)))
+    }
   }
 
   test("cast MapType propagates Unsupported from nested value cast") {
diff --git 
a/spark/src/test/scala/org/apache/comet/exec/CometAggregateSuite.scala 
b/spark/src/test/scala/org/apache/comet/exec/CometAggregateSuite.scala
index 2a3e19f1fa..2aaf3eb829 100644
--- a/spark/src/test/scala/org/apache/comet/exec/CometAggregateSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/exec/CometAggregateSuite.scala
@@ -158,11 +158,9 @@ class CometAggregateSuite extends CometTestBase with 
AdaptiveSparkPlanHelper {
       withTable(tableName) {
         val table = spark.read.parquet(filename).coalesce(1)
         table.createOrReplaceTempView(tableName)
-        // we fall back to Spark for avg on decimal due to the following issue
-        // https://github.com/apache/datafusion-comet/issues/1371
-        // once this is fixed, we should change this test to
-        // checkSparkAnswerAndNumOfAggregates
-        checkSparkAnswer(s"SELECT c1, avg(c7) FROM $tableName GROUP BY c1 
ORDER BY c1")
+        checkSparkAnswerAndNumOfAggregates(
+          s"SELECT c1, avg(c7) FROM $tableName GROUP BY c1 ORDER BY c1",
+          2)
       }
     }
   }
diff --git 
a/spark/src/test/scala/org/apache/spark/sql/benchmark/CometCastNumericToNumericBenchmark.scala
 
b/spark/src/test/scala/org/apache/spark/sql/benchmark/CometCastNumericToNumericBenchmark.scala
index a9ea19a0e9..2141b1bea0 100644
--- 
a/spark/src/test/scala/org/apache/spark/sql/benchmark/CometCastNumericToNumericBenchmark.scala
+++ 
b/spark/src/test/scala/org/apache/spark/sql/benchmark/CometCastNumericToNumericBenchmark.scala
@@ -81,6 +81,15 @@ object CometCastNumericToNumericBenchmark extends 
CometBenchmarkBase {
     ("DECIMAL(10,2)", "c_decimal", "LONG"),
     ("DECIMAL(10,2)", "c_decimal", "DOUBLE"))
 
+  // Float and double to decimal. The native cast rounds the binary product 
when that provably
+  // agrees with Spark and otherwise formats the shortest decimal string, so 
low-scale targets
+  // exercise the arithmetic path and high-scale targets exercise the string 
path.
+  private val floatToDecimalPairs = Seq(
+    ("FLOAT", "c_float", "DECIMAL(10,2)"),
+    ("FLOAT", "c_float", "DECIMAL(38,18)"),
+    ("DOUBLE", "c_double", "DECIMAL(10,2)"),
+    ("DOUBLE", "c_double", "DECIMAL(38,18)"))
+
   private def generateConfigs(
       pairs: Seq[(String, String, String)]): Seq[CastNumericToNumericConfig] = 
{
     for {
@@ -137,7 +146,8 @@ object CometCastNumericToNumericBenchmark extends 
CometBenchmarkBase {
             generateConfigs(floatPairs) ++
             generateConfigs(intToFloatPairs) ++
             generateConfigs(floatToIntPairs) ++
-            generateConfigs(decimalPairs)).foreach { config =>
+            generateConfigs(decimalPairs) ++
+            generateConfigs(floatToDecimalPairs)).foreach { config =>
             runExpressionBenchmark(config.name, v, config.query, 
config.extraCometConfigs)
           }
         }


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

Reply via email to