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

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 0f049de7 fix(table): match Java NaN ordering in aggregation (#588)
0f049de7 is described below

commit 0f049de7d148546cc473c9d111336fbc0bd233fa
Author: Wuhen- Li <[email protected]>
AuthorDate: Thu Jul 23 12:08:09 2026 +0800

    fix(table): match Java NaN ordering in aggregation (#588)
---
 crates/paimon/src/table/aggregator/numeric.rs | 117 +++++++++++++++++++++-----
 1 file changed, 97 insertions(+), 20 deletions(-)

diff --git a/crates/paimon/src/table/aggregator/numeric.rs 
b/crates/paimon/src/table/aggregator/numeric.rs
index 38eb794b..8ab7b6d2 100644
--- a/crates/paimon/src/table/aggregator/numeric.rs
+++ b/crates/paimon/src/table/aggregator/numeric.rs
@@ -438,15 +438,23 @@ fn agg_minmax(
         ($acc:expr, $ty:ty) => {{
             let v = downcast::<$ty>(array, field_name)?.value(row_idx);
             // Match Java `Float.compare` / `Double.compare`, which order NaN
-            // greater than any other value (including +Infinity).  Using
-            // `total_cmp` makes that ordering explicit and deterministic.
+            // greater than any other value (including +Infinity) and compare
+            // all NaN representations as equal.  For non-NaN values,
+            // `total_cmp` preserves Java's ordering of -0.0 before +0.0.
             *$acc = Some(match *$acc {
                 None => v,
                 Some(prev) => {
-                    let cmp = v.total_cmp(&prev);
+                    let cmp = match (v.is_nan(), prev.is_nan()) {
+                        (true, true) => std::cmp::Ordering::Equal,
+                        (true, false) => std::cmp::Ordering::Greater,
+                        (false, true) => std::cmp::Ordering::Less,
+                        (false, false) => v.total_cmp(&prev),
+                    };
                     let take_new = if keep_smaller {
-                        cmp.is_lt()
+                        // Java `FieldMinAgg` returns the input on ties.
+                        cmp.is_le()
                     } else {
+                        // Java `FieldMaxAgg` retains the accumulator on ties.
                         cmp.is_gt()
                     };
                     if take_new {
@@ -926,25 +934,94 @@ mod tests {
     }
 
     #[test]
-    fn test_min_max_treat_nan_as_largest() {
-        // Match Java `Float.compare(NaN, x) > 0`: NaN is greater than every
-        // other value, so min skips it and max picks it.
-        let mut min = min_agg(DataType::Float(FloatType::new()));
-        let arr = Float32Array::from(vec![Some(f32::NAN), Some(1.0), 
Some(0.5)]);
-        for i in 0..arr.len() {
-            min.agg(&arr, i).unwrap();
+    fn test_float32_min_max_matches_java_ordering() {
+        let aggregate = |values: &[f32], keep_smaller: bool| {
+            let arr = Float32Array::from_iter_values(values.iter().copied());
+            let mut agg: Box<dyn FieldAggregator> = if keep_smaller {
+                Box::new(min_agg(DataType::Float(FloatType::new())))
+            } else {
+                Box::new(max_agg(DataType::Float(FloatType::new())))
+            };
+            for i in 0..arr.len() {
+                agg.agg(&arr, i).unwrap();
+            }
+            let result = agg.result().unwrap();
+            result
+                .as_any()
+                .downcast_ref::<Float32Array>()
+                .unwrap()
+                .value(0)
+        };
+
+        let negative_nan = f32::from_bits(0xffc0_0001);
+        let positive_nan = f32::from_bits(0x7fc0_0002);
+        assert!(negative_nan.is_nan());
+        assert!(positive_nan.is_nan());
+
+        // Every NaN sorts after finite values, including when the accumulator
+        // already contains NaN before the finite value arrives.
+        for nan in [negative_nan, positive_nan] {
+            assert_eq!(aggregate(&[nan, 1.0], true).to_bits(), 
1.0f32.to_bits());
+            assert_eq!(aggregate(&[nan, 1.0], false).to_bits(), nan.to_bits());
+            assert_eq!(aggregate(&[1.0, nan], true).to_bits(), 
1.0f32.to_bits());
+            assert_eq!(aggregate(&[1.0, nan], false).to_bits(), nan.to_bits());
         }
-        let v = min.result().unwrap();
-        let v = v.as_any().downcast_ref::<Float32Array>().unwrap().value(0);
-        assert!((v - 0.5).abs() < 1e-6);
 
-        let mut max = max_agg(DataType::Float(FloatType::new()));
-        for i in 0..arr.len() {
-            max.agg(&arr, i).unwrap();
+        assert_eq!(aggregate(&[0.0, -0.0], true).to_bits(), 
(-0.0f32).to_bits());
+        assert_eq!(aggregate(&[-0.0, 0.0], false).to_bits(), 0.0f32.to_bits());
+        assert_eq!(aggregate(&[3.0, -2.0, 1.0], true), -2.0);
+        assert_eq!(aggregate(&[3.0, -2.0, 1.0], false), 3.0);
+
+        // Java canonicalizes NaNs for comparison only.  On equal values,
+        // `FieldMinAgg` takes the new input while `FieldMaxAgg` retains the
+        // accumulator, without rewriting either NaN's exact bits.
+        for values in [[negative_nan, positive_nan], [positive_nan, 
negative_nan]] {
+            assert_eq!(aggregate(&values, true).to_bits(), 
values[1].to_bits());
+            assert_eq!(aggregate(&values, false).to_bits(), 
values[0].to_bits());
+        }
+    }
+
+    #[test]
+    fn test_float64_min_max_matches_java_ordering() {
+        let aggregate = |values: &[f64], keep_smaller: bool| {
+            let arr = Float64Array::from_iter_values(values.iter().copied());
+            let mut agg: Box<dyn FieldAggregator> = if keep_smaller {
+                Box::new(min_agg(DataType::Double(DoubleType::new())))
+            } else {
+                Box::new(max_agg(DataType::Double(DoubleType::new())))
+            };
+            for i in 0..arr.len() {
+                agg.agg(&arr, i).unwrap();
+            }
+            let result = agg.result().unwrap();
+            result
+                .as_any()
+                .downcast_ref::<Float64Array>()
+                .unwrap()
+                .value(0)
+        };
+
+        let negative_nan = f64::from_bits(0xfff8_0000_0000_0001);
+        let positive_nan = f64::from_bits(0x7ff8_0000_0000_0002);
+        assert!(negative_nan.is_nan());
+        assert!(positive_nan.is_nan());
+
+        for nan in [negative_nan, positive_nan] {
+            assert_eq!(aggregate(&[nan, 1.0], true).to_bits(), 
1.0f64.to_bits());
+            assert_eq!(aggregate(&[nan, 1.0], false).to_bits(), nan.to_bits());
+            assert_eq!(aggregate(&[1.0, nan], true).to_bits(), 
1.0f64.to_bits());
+            assert_eq!(aggregate(&[1.0, nan], false).to_bits(), nan.to_bits());
+        }
+
+        assert_eq!(aggregate(&[0.0, -0.0], true).to_bits(), 
(-0.0f64).to_bits());
+        assert_eq!(aggregate(&[-0.0, 0.0], false).to_bits(), 0.0f64.to_bits());
+        assert_eq!(aggregate(&[3.0, -2.0, 1.0], true), -2.0);
+        assert_eq!(aggregate(&[3.0, -2.0, 1.0], false), 3.0);
+
+        for values in [[negative_nan, positive_nan], [positive_nan, 
negative_nan]] {
+            assert_eq!(aggregate(&values, true).to_bits(), 
values[1].to_bits());
+            assert_eq!(aggregate(&values, false).to_bits(), 
values[0].to_bits());
         }
-        let v = max.result().unwrap();
-        let v = v.as_any().downcast_ref::<Float32Array>().unwrap().value(0);
-        assert!(v.is_nan(), "max should pick NaN, got {v}");
     }
 
     #[test]

Reply via email to