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

pitrou pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow.git


The following commit(s) were added to refs/heads/main by this push:
     new 445851a3b3 GH-50182: [C++][Parquet] Fix truncated min/max statistics 
for all-infinity floating-point columns (#50183)
445851a3b3 is described below

commit 445851a3b3b22891977ac1a8b5ceabf42e6bbf52
Author: Chungmin Lee <[email protected]>
AuthorDate: Thu Jun 25 04:03:16 2026 -0700

    GH-50182: [C++][Parquet] Fix truncated min/max statistics for all-infinity 
floating-point columns (#50183)
    
    ### Rationale for this change
    
    When every value in a `FLOAT`, `DOUBLE`, or `FLOAT16` column is the same 
infinity, the Parquet writer stored a *finite* min/max statistic instead of the 
infinity actually present in the column:
    
    - all `+Inf` → `min` written as `FLT_MAX` / `DBL_MAX` (should be `+Inf`)
    - all `-Inf` → `max` written as `-FLT_MAX` / `-DBL_MAX` (should be `-Inf`)
    
    The running min/max were seeded with `std::numeric_limits<T>::max()` / 
`::lowest()` — the largest/smallest *finite* values. These are not identity 
elements for min/max once the data contains infinities: `Min(FLT_MAX, +Inf)` 
keeps `FLT_MAX`, so an all-`+Inf` column never displaces the seed (and 
symmetrically for `-Inf`). The wrong value is then written with 
`is_min_value_exact = true`, so a reader is told the exact minimum of an 
all-`+Inf` column is `FLT_MAX` — a value not even present [...]
    
    See GH-50182 for a full reproduction in both PyArrow and C++.
    
    ### What changes are included in this PR?
    
    - `CompareHelper::DefaultMin()` / `DefaultMax()` now seed floating-point 
accumulation with `+Inf` / `-Inf` (the true identities) instead of the largest 
finite values; integral types are unchanged.
    - The same change is applied to the `Float16` comparator, via new 
`Float16Constants::positive_infinity()` / `negative_infinity()`.
    - The "no valid value was seen" detection in `CleanStatistic` / 
`CleanFloat16Statistic` (which relied on the old finite seeds) is updated to 
match the new `±Inf` seeds. An all-NaN or empty column still leaves `min = 
+Inf, max = -Inf` (`min > max`, which real data can never produce), so it stays 
unambiguously detectable and continues to emit no statistics.
    
    ### Are these changes tested?
    
    Yes. A new typed test `TestFloatStatistics.Infinities` (covering `FLOAT`, 
`DOUBLE`, and `FLOAT16`) asserts that all-`+Inf`, all-`-Inf`, and mixed `±Inf` 
columns produce the exact infinity as min/max (the existing `AssertMinMaxAre` 
helper also checks `is_min_value_exact`/`is_max_value_exact`). The existing 
all-NaN tests continue to pass, exercising the updated empty/all-NaN detection.
    
    ### Are there any user-facing changes?
    
    Parquet files written for all-infinity floating-point columns now record 
the correct `±Inf` min/max statistics instead of the largest finite value. No 
public API signatures change.
    
    Two minor behavior changes on existing public low-level APIs are worth 
noting (the normal writer accumulation path is unaffected, since a column with 
≥1 non-NaN value always yields `min ≤ max`):
    - `TypedComparator<FloatType/DoubleType/Float16LogicalType>::GetMinMax()` 
now returns `(+Inf, -Inf)` for an all-NaN (or empty) batch, where it previously 
returned the largest/smallest finite value as a sentinel.
    - `TypedStatistics::SetMinMax()` now treats any inverted (`min > max`) 
input as "no valid statistics", whereas it previously only special-cased that 
one exact finite sentinel pair.
    
    **This PR contains a "Critical Fix".** It fixes a bug that produced 
incorrect data: the writer emitted min/max statistics that are wrong yet 
flagged exact (`is_min_value_exact = true`), which can yield incorrect results 
for readers that evaluate `MIN`/`MAX` directly from Parquet statistics.
    
    * GitHub Issue: #50182
    
    Lead-authored-by: Chungmin Lee <[email protected]>
    Co-authored-by: Copilot <[email protected]>
    Signed-off-by: Antoine Pitrou <[email protected]>
---
 cpp/src/parquet/statistics.cc      | 57 ++++++++++++++++++++++++++++++--------
 cpp/src/parquet/statistics_test.cc | 52 ++++++++++++++++++++++++++++++++++
 2 files changed, 97 insertions(+), 12 deletions(-)

diff --git a/cpp/src/parquet/statistics.cc b/cpp/src/parquet/statistics.cc
index 09c48ef0ff..d43998ef78 100644
--- a/cpp/src/parquet/statistics.cc
+++ b/cpp/src/parquet/statistics.cc
@@ -58,18 +58,23 @@ constexpr int value_length(int type_length, const FLBA& 
value) { return type_len
 // Static "constants" for normalizing float16 min/max values. These need to be 
expressed
 // as pointers because `Float16LogicalType` represents an FLBA.
 struct Float16Constants {
-  static constexpr const uint8_t* lowest() { return lowest_.data(); }
-  static constexpr const uint8_t* max() { return max_.data(); }
   static constexpr const uint8_t* positive_zero() { return 
positive_zero_.data(); }
   static constexpr const uint8_t* negative_zero() { return 
negative_zero_.data(); }
+  static constexpr const uint8_t* positive_infinity() {
+    return positive_infinity_.data();
+  }
+  static constexpr const uint8_t* negative_infinity() {
+    return negative_infinity_.data();
+  }
 
  private:
   using Bytes = std::array<uint8_t, 2>;
-  static constexpr Bytes lowest_ =
-      std::numeric_limits<Float16>::lowest().ToLittleEndian();
-  static constexpr Bytes max_ = 
std::numeric_limits<Float16>::max().ToLittleEndian();
   static constexpr Bytes positive_zero_ = 
(+Float16::FromBits(0)).ToLittleEndian();
   static constexpr Bytes negative_zero_ = 
(-Float16::FromBits(0)).ToLittleEndian();
+  static constexpr Bytes positive_infinity_ =
+      std::numeric_limits<Float16>::infinity().ToLittleEndian();
+  static constexpr Bytes negative_infinity_ =
+      (-std::numeric_limits<Float16>::infinity()).ToLittleEndian();
 };
 
 template <typename DType, bool is_signed>
@@ -79,8 +84,24 @@ struct CompareHelper {
   static_assert(!std::is_unsigned<T>::value || std::is_same<T, bool>::value,
                 "T is an unsigned numeric");
 
-  constexpr static T DefaultMin() { return std::numeric_limits<T>::max(); }
-  constexpr static T DefaultMax() { return std::numeric_limits<T>::lowest(); }
+  // For floating point, seed the running min/max with +/-infinity rather than
+  // the largest/smallest finite value: a finite seed is not an identity for
+  // min/max once the data contains infinities, so an all-+Inf (resp. all--Inf)
+  // column would otherwise never displace the seed and report a finite bound.
+  constexpr static T DefaultMin() {
+    if constexpr (std::is_floating_point_v<T>) {
+      return std::numeric_limits<T>::infinity();
+    } else {
+      return std::numeric_limits<T>::max();
+    }
+  }
+  constexpr static T DefaultMax() {
+    if constexpr (std::is_floating_point_v<T>) {
+      return -std::numeric_limits<T>::infinity();
+    } else {
+      return std::numeric_limits<T>::lowest();
+    }
+  }
 
   // MSVC17 fix, isnan is not overloaded for IntegralType as per C++11
   // standard requirements.
@@ -300,8 +321,8 @@ template <>
 struct CompareHelper<Float16LogicalType, /*is_signed=*/true> {
   using T = FLBA;
 
-  static T DefaultMin() { return T{Float16Constants::max()}; }
-  static T DefaultMax() { return T{Float16Constants::lowest()}; }
+  static T DefaultMin() { return T{Float16Constants::positive_infinity()}; }
+  static T DefaultMax() { return T{Float16Constants::negative_infinity()}; }
 
   static T Coalesce(T val, T fallback) {
     return (val.ptr == nullptr || Float16::FromLittleEndian(val.ptr).is_nan()) 
? fallback
@@ -330,6 +351,17 @@ struct CompareHelper<Float16LogicalType, 
/*is_signed=*/true> {
 
 using ::std::optional;
 
+// A usable min/max pair always satisfies min <= max. The reverse ordering
+// (min > max) is produced only by the inverted DefaultMin()/DefaultMax() seeds
+// -- left in place when no valid, non-NaN value was observed -- or by an
+// inverted caller-supplied range; in either case there is no statistic to 
emit.
+// Testing the ordering keeps this independent of the specific seed values, so 
it
+// cannot drift from DefaultMin()/DefaultMax().
+template <typename T>
+bool IsInvalidMinMax(const T& min, const T& max) {
+  return max < min;
+}
+
 template <typename T>
 ::arrow::enable_if_t<std::is_integral<T>::value, optional<std::pair<T, T>>>
 CleanStatistic(std::pair<T, T> min_max, LogicalType::Type::type) {
@@ -357,7 +389,9 @@ CleanStatistic(std::pair<T, T> min_max, 
LogicalType::Type::type) {
     return ::std::nullopt;
   }
 
-  if (min == std::numeric_limits<T>::max() && max == 
std::numeric_limits<T>::lowest()) {
+  // Discard an inverted min/max: either an empty/all-NaN input left the seeds 
in
+  // place, or the supplied range is invalid. A real value always has min <= 
max.
+  if (IsInvalidMinMax(min, max)) {
     return ::std::nullopt;
   }
 
@@ -384,8 +418,7 @@ optional<std::pair<FLBA, FLBA>> 
CleanFloat16Statistic(std::pair<FLBA, FLBA> min_
     return ::std::nullopt;
   }
 
-  if (min == std::numeric_limits<Float16>::max() &&
-      max == std::numeric_limits<Float16>::lowest()) {
+  if (IsInvalidMinMax(min, max)) {
     return ::std::nullopt;
   }
 
diff --git a/cpp/src/parquet/statistics_test.cc 
b/cpp/src/parquet/statistics_test.cc
index 0d300b856c..bf0961c4fc 100644
--- a/cpp/src/parquet/statistics_test.cc
+++ b/cpp/src/parquet/statistics_test.cc
@@ -1485,6 +1485,7 @@ class TestFloatStatistics : public ::testing::Test {
   }
 
   void TestNaNs();
+  void TestInfinities();
 
  protected:
   std::vector<uint8_t> data_buf_;
@@ -1559,6 +1560,30 @@ void TestFloatStatistics<T>::TestNaNs() {
                   valid_bitmap_no_nans);
 }
 
+// GH-50182: an all-infinity column must report that infinity as its min/max,
+// rather than the largest finite value (a finite seed is never displaced by an
+// infinity of the same sign). A NaN interspersed in such a column must be
+// ignored without corrupting the infinite min/max.
+template <typename T>
+void TestFloatStatistics<T>::TestInfinities() {
+  NodePtr node = this->MakeNode("f", Repetition::REQUIRED);
+  ColumnDescriptor descr(node, 0, 0);
+
+  constexpr c_type inf = std::numeric_limits<c_type>::infinity();
+  constexpr c_type nan = std::numeric_limits<c_type>::quiet_NaN();
+  std::vector<c_type> all_pos_inf{inf, inf, inf};
+  std::vector<c_type> all_neg_inf{-inf, -inf, -inf};
+  std::vector<c_type> mixed_inf{inf, -inf};
+  std::vector<c_type> pos_inf_with_nan{inf, nan, inf};
+  std::vector<c_type> neg_inf_with_nan{-inf, nan, -inf};
+
+  AssertMinMaxAre(MakeStatistics<ParquetType>(&descr), all_pos_inf, inf, inf);
+  AssertMinMaxAre(MakeStatistics<ParquetType>(&descr), all_neg_inf, -inf, 
-inf);
+  AssertMinMaxAre(MakeStatistics<ParquetType>(&descr), mixed_inf, -inf, inf);
+  AssertMinMaxAre(MakeStatistics<ParquetType>(&descr), pos_inf_with_nan, inf, 
inf);
+  AssertMinMaxAre(MakeStatistics<ParquetType>(&descr), neg_inf_with_nan, -inf, 
-inf);
+}
+
 struct BufferedFloat16 {
   explicit BufferedFloat16(Float16 f16) : f16(f16) {
     this->f16.ToLittleEndian(bytes_.data());
@@ -1611,12 +1636,39 @@ void 
TestFloatStatistics<Float16LogicalType>::TestNaNs() {
                   valid_bitmap_no_nans);
 }
 
+template <>
+void TestFloatStatistics<Float16LogicalType>::TestInfinities() {
+  NodePtr node = this->MakeNode("f", Repetition::REQUIRED);
+  ColumnDescriptor descr(node, 0, 0);
+
+  using F16 = BufferedFloat16;
+  const auto pos_inf = F16(std::numeric_limits<Float16>::infinity());
+  const auto neg_inf = F16(-std::numeric_limits<Float16>::infinity());
+  const auto nan = F16(std::numeric_limits<Float16>::quiet_NaN());
+  const auto pinf = FLBA{pos_inf.bytes()};
+  const auto ninf = FLBA{neg_inf.bytes()};
+  const auto fnan = FLBA{nan.bytes()};
+
+  std::vector<FLBA> all_pos_inf{pinf, pinf, pinf};
+  std::vector<FLBA> all_neg_inf{ninf, ninf, ninf};
+  std::vector<FLBA> mixed_inf{pinf, ninf};
+  std::vector<FLBA> pos_inf_with_nan{pinf, fnan, pinf};
+  std::vector<FLBA> neg_inf_with_nan{ninf, fnan, ninf};
+
+  AssertMinMaxAre(MakeStatistics<ParquetType>(&descr), all_pos_inf, pinf, 
pinf);
+  AssertMinMaxAre(MakeStatistics<ParquetType>(&descr), all_neg_inf, ninf, 
ninf);
+  AssertMinMaxAre(MakeStatistics<ParquetType>(&descr), mixed_inf, ninf, pinf);
+  AssertMinMaxAre(MakeStatistics<ParquetType>(&descr), pos_inf_with_nan, pinf, 
pinf);
+  AssertMinMaxAre(MakeStatistics<ParquetType>(&descr), neg_inf_with_nan, ninf, 
ninf);
+}
+
 using FloatingPointTypes = ::testing::Types<FloatType, DoubleType, 
Float16LogicalType>;
 
 TYPED_TEST_SUITE(TestFloatStatistics, FloatingPointTypes);
 
 TYPED_TEST(TestFloatStatistics, NegativeZeros) { this->TestNegativeZeroes(); }
 TYPED_TEST(TestFloatStatistics, NaNs) { this->TestNaNs(); }
+TYPED_TEST(TestFloatStatistics, Infinities) { this->TestInfinities(); }
 
 // ARROW-7376
 TEST(TestStatisticsSortOrderFloatNaN, NaNAndNullsInfiniteLoop) {

Reply via email to