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

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


The following commit(s) were added to refs/heads/main by this push:
     new 13b6d07f feat(arrow/compute): add IEEE 754 total-order float sorting 
option (#1430)
13b6d07f is described below

commit 13b6d07f83095ad02dcd8747a0cc724f5168a22f
Author: Matt Topol <[email protected]>
AuthorDate: Wed Oct 28 20:06:20 2026 +0000

    feat(arrow/compute): add IEEE 754 total-order float sorting option (#1430)
    
    ### Rationale for this change
    
    The sort kernels order floats the way Arrow C++ does: numerically with
    `-0 == +0`, and every NaN (whatever its sign) grouped next to the nulls
    according to `NullPlacement`, regardless of `Order`. Some consumers need
    a deterministic total order over floats that distinguishes `-0`/`+0` and
    signed NaNs and treats NaN as an ordinary value, matching Java's
    `Double.compare` / IEEE 754 `totalOrder`.
    
    ### What changes are included in this PR?
    
    - New `SortKey.FloatTotalOrder bool` field:
      - `false` (default): existing behavior, unchanged.
      - `true`: IEEE 754 totalOrder for float16/float32/float64:
    `-NaN < -Inf < negative values < -0 < +0 < positive values < +Inf <
    +NaN`.
    NaNs sort as values (not null-like), and `Descending` reverses the whole
    order including NaNs.
    - Nulls are unaffected: they still follow `NullPlacement` in both modes.
    - Non-float columns ignore the field.
    - In total-order mode the float comparators compare order-preserving
    integer keys derived from the raw bits, and report no null-like values
    so NaNs are not partitioned out.
    - The single-chunk, no-null radix fast path (previously integers only)
    now also handles total-order floats by radix sorting the materialized
    integer keys.
    
    ### Are these changes tested?
    
    Yes:
    - `TestSortIndicesFloatTotalOrder`: float64/32/16 × ascending/descending
    × nulls first/last, over the single-array, chunked (per-chunk sort +
    merge) and multi-key record batch paths, with -NaN, NaN, ±Inf,
    interleaved ±0 and a null.
    - `TestSortIndicesFloatDefaultOrderingUnchanged`: pins the default
    behavior.
    - `TestSortIndicesSingleChunkRadixFloatTotalOrder`: radix fast path
    (sliced chunk, 4096 rows) for all three float widths, checked against an
    independent reference comparator.
    
    ### Are there any user-facing changes?
    
    Yes, a new opt-in `SortKey.FloatTotalOrder` field. The default is
    unchanged.
---
 arrow/compute/internal/kernels/vector_sort.go      |  33 +++-
 .../internal/kernels/vector_sort_bench_test.go     |  71 ++++++++
 .../internal/kernels/vector_sort_physical.go       |  63 +++++++
 .../compute/internal/kernels/vector_sort_radix.go  |  39 ++++
 .../internal/kernels/vector_sort_radix_test.go     | 199 +++++++++++++++++++++
 .../internal/kernels/vector_sort_support.go        |  19 ++
 arrow/compute/vector_sort.go                       |   3 +-
 arrow/compute/vector_sort_test.go                  | 167 +++++++++++++++++
 8 files changed, 588 insertions(+), 6 deletions(-)

diff --git a/arrow/compute/internal/kernels/vector_sort.go 
b/arrow/compute/internal/kernels/vector_sort.go
index 025c3dd3..7f09beb0 100644
--- a/arrow/compute/internal/kernels/vector_sort.go
+++ b/arrow/compute/internal/kernels/vector_sort.go
@@ -53,11 +53,24 @@ func (SortOptions) TypeName() string { return "SortOptions" 
}
 
 type SortState = SortOptions
 
-// SortKey defines a column to sort by with its ordering and null placement 
options.
+// SortKey defines a column to sort by with its ordering, null placement and 
float ordering options.
 type SortKey struct {
        ColumnIndex   int
        Order         SortOrder
        NullPlacement NullPlacement
+       // FloatTotalOrder orders float16/float32/float64 columns by the IEEE 
754 totalOrder
+       // predicate: -NaN < -Inf < negative values < -0 < +0 < positive values 
< +Inf < +NaN.
+       // NaNs are ordered as values (not null-like) and Descending reverses 
the whole order,
+       // NaNs included; nulls still follow NullPlacement. When false (the 
default), floats
+       // compare numerically with -0 == +0 and every NaN, regardless of sign, 
is grouped next
+       // to the nulls according to NullPlacement, independent of Order (Arrow 
C++ behavior).
+       // Non-float columns ignore it.
+       //
+       // Total order is usually faster than the default ordering, since each 
value maps to an
+       // integer key and no NaN checks are needed. When it is the only sort 
key and the column
+       // is a single chunk of at least 2048 rows without nulls, the sort is a 
radix sort over
+       // those keys, which allocates a temporary key buffer the size of the 
column's values.
+       FloatTotalOrder bool
 }
 
 // Chunk-aware sort_indices: logical row IDs 0..n-1, no chunk concatenation. 
Structure follows
@@ -161,8 +174,8 @@ func newFixedSizeBinaryComparator(chunks []arrow.Array, 
numRows int, vn bool, ro
 }
 
 // createChunkedComparator builds a column comparator for these chunks (one 
Arrow type for all chunks).
-// rowMap may be shared by aligned multi-key columns.
-func createChunkedComparator(chunks []arrow.Array, numRows int, rowMap 
logicalRowMap) (columnComparator, error) {
+// rowMap may be shared by aligned multi-key columns. floatTotalOrder enables 
or disables IEEE 754 totalOrder for floats.
+func createChunkedComparator(chunks []arrow.Array, numRows int, rowMap 
logicalRowMap, floatTotalOrder bool) (columnComparator, error) {
        if len(chunks) == 0 {
                return nil, fmt.Errorf("%w: cannot create comparator for empty 
chunk list", arrow.ErrInvalid)
        }
@@ -202,10 +215,19 @@ func createChunkedComparator(chunks []arrow.Array, 
numRows int, rowMap logicalRo
        case arrow.UINT64:
                return newPhysicalSortUint64Column(chunks, numRows, 
validityNulls, rowMap), nil
        case arrow.FLOAT16:
+               if floatTotalOrder {
+                       return 
&physicalSortFloat16TotalOrderColumn{physicalSortFloatTotalOrderBase{newPhysicalColumnBase(chunks,
 numRows, validityNulls, rowMap)}}, nil
+               }
                return newPhysicalSortFloat16Column(chunks, numRows, 
validityNulls, rowMap), nil
        case arrow.FLOAT32:
+               if floatTotalOrder {
+                       return 
&physicalSortFloat32TotalOrderColumn{physicalSortFloatTotalOrderBase{newPhysicalColumnBase(chunks,
 numRows, validityNulls, rowMap)}}, nil
+               }
                return newPhysicalSortFloat32Column(chunks, numRows, 
validityNulls, rowMap), nil
        case arrow.FLOAT64:
+               if floatTotalOrder {
+                       return 
&physicalSortFloat64TotalOrderColumn{physicalSortFloatTotalOrderBase{newPhysicalColumnBase(chunks,
 numRows, validityNulls, rowMap)}}, nil
+               }
                return newPhysicalSortFloat64Column(chunks, numRows, 
validityNulls, rowMap), nil
        case arrow.DECIMAL32:
                return newPhysicalSortDecimal32Column(chunks, numRows, 
validityNulls, rowMap), nil
@@ -420,7 +442,7 @@ func SortIndices(ctx *exec.KernelCtx, columns 
[]*arrow.Chunked, keys []SortKey)
 
        comparators := make([]columnComparator, len(columns))
        for i, col := range columns {
-               comp, err := createChunkedComparator(col.Chunks(), nRows, 
sharedRowMap)
+               comp, err := createChunkedComparator(col.Chunks(), nRows, 
sharedRowMap, keys[i].FloatTotalOrder)
                if err != nil {
                        return nil, err
                }
@@ -456,7 +478,8 @@ func SortIndices(ctx *exec.KernelCtx, columns 
[]*arrow.Chunked, keys []SortKey)
                        k0 := keys[0]
                        c0 := comparators[0]
                        if nRows >= radixSortMinRows && 
!c0.columnHasValidityNulls() &&
-                               sortIndicesSingleIntRadix(ctx, chunks[0], 
indices, k0.Order) {
+                               (sortIndicesSingleIntRadix(ctx, chunks[0], 
indices, k0.Order) ||
+                                       (k0.FloatTotalOrder && 
sortIndicesSingleFloatTotalOrderRadix(ctx, chunks[0], indices, k0.Order))) {
                                out.Buffers[1].WrapBuffer(buf)
                                return out, nil
                        }
diff --git a/arrow/compute/internal/kernels/vector_sort_bench_test.go 
b/arrow/compute/internal/kernels/vector_sort_bench_test.go
index 31f735c7..621e8741 100644
--- a/arrow/compute/internal/kernels/vector_sort_bench_test.go
+++ b/arrow/compute/internal/kernels/vector_sort_bench_test.go
@@ -21,6 +21,7 @@ package kernels
 import (
        "context"
        "fmt"
+       "math"
        "testing"
 
        "github.com/apache/arrow-go/v18/arrow"
@@ -215,3 +216,73 @@ func BenchmarkSortIndices_Int64_Distributions(b 
*testing.B) {
                }
        }
 }
+
+// makeChunkedFloat64Split returns n float64 rows in numChunks contiguous 
arrays. Every
+// nullEvery-th row is null and every nanEvery-th row is NaN (0 disables 
either).
+func makeChunkedFloat64Split(tb testing.TB, mem memory.Allocator, n, 
numChunks, nullEvery, nanEvery int) *arrow.Chunked {
+       tb.Helper()
+       chunks := make([]arrow.Array, 0, numChunks)
+       base, rem, global := n/numChunks, n%numChunks, 0
+       for c := range numChunks {
+               sz := base
+               if c < rem {
+                       sz++
+               }
+               bld := array.NewFloat64Builder(mem)
+               for i := range sz {
+                       g := global + i
+                       switch {
+                       case nullEvery > 0 && g%nullEvery == 0:
+                               bld.AppendNull()
+                       case nanEvery > 0 && g%nanEvery == 0:
+                               bld.Append(math.NaN())
+                       default:
+                               x := uint64(g)
+                               
bld.Append(float64(int64((x*6364136223846793005)^(x>>12))) / 1e6)
+                       }
+               }
+               chunks = append(chunks, bld.NewArray())
+               bld.Release()
+               global += sz
+       }
+       ch := arrow.NewChunked(arrow.PrimitiveTypes.Float64, chunks)
+       for _, arr := range chunks {
+               arr.Release()
+       }
+       tb.Cleanup(func() { ch.Release() })
+       return ch
+}
+
+// BenchmarkSortIndices_Float64 compares the default float ordering with
+// SortKey.FloatTotalOrder. Without nulls and with a single chunk, total order
+// takes the radix path; nulls or multiple chunks use the comparator sort.
+func BenchmarkSortIndices_Float64(b *testing.B) {
+       const rows = 65536
+       for _, tc := range []struct {
+               name                string
+               chunks, nulls, nans int
+       }{
+               {"chunks=1", 1, 0, 0},
+               {"chunks=1/nans", 1, 0, 97},
+               {"chunks=16", 16, 0, 0},
+               {"chunks=16/nulls/nans", 16, 31, 97},
+       } {
+               for _, totalOrder := range []bool{false, true} {
+                       b.Run(fmt.Sprintf("%s/totalOrder=%t", tc.name, 
totalOrder), func(b *testing.B) {
+                               ctx, mem := newBenchKernelCtx(b)
+                               col := makeChunkedFloat64Split(b, mem, rows, 
tc.chunks, tc.nulls, tc.nans)
+                               keys := []SortKey{{ColumnIndex: 0, Order: 
Ascending, NullPlacement: NullsAtEnd, FloatTotalOrder: totalOrder}}
+                               columns := []*arrow.Chunked{col}
+
+                               b.ReportAllocs()
+                               for b.Loop() {
+                                       res, err := SortIndices(ctx, columns, 
keys)
+                                       if err != nil {
+                                               b.Fatal(err)
+                                       }
+                                       res.Release()
+                               }
+                       })
+               }
+       }
+}
diff --git a/arrow/compute/internal/kernels/vector_sort_physical.go 
b/arrow/compute/internal/kernels/vector_sort_physical.go
index 60d52b87..642253be 100644
--- a/arrow/compute/internal/kernels/vector_sort_physical.go
+++ b/arrow/compute/internal/kernels/vector_sort_physical.go
@@ -543,6 +543,69 @@ func (c *physicalSortFloat64Column) 
columnHasValidityNulls() bool {
        return c.base.columnHasValidityNulls()
 }
 
+// Total-order float comparators (SortKey.FloatTotalOrder) compare values by 
their IEEE 754
+// totalOrder key. They are separate types so the default float comparators 
above carry no
+// per-comparison check for the option. NaNs are ordinary values, so these 
columns report no
+// null-like values; validity nulls still follow NullPlacement.
+type physicalSortFloatTotalOrderBase struct{ base physicalColumnBase }
+
+func (c *physicalSortFloatTotalOrderBase) isNullAt(row uint64) bool {
+       return c.base.isNullAtGlobal(row)
+}
+func (c *physicalSortFloatTotalOrderBase) hasNullLikeValues() bool  { return 
false }
+func (c *physicalSortFloatTotalOrderBase) isNullLikeAt(uint64) bool { return 
false }
+func (c *physicalSortFloatTotalOrderBase) columnHasValidityNulls() bool {
+       return c.base.columnHasValidityNulls()
+}
+
+type physicalSortFloat16TotalOrderColumn struct {
+       physicalSortFloatTotalOrderBase
+}
+
+func (c *physicalSortFloat16TotalOrderColumn) compareRowsForKey(i, j uint64, 
key SortKey) int {
+       ai, aj, li, lj := c.base.pair(i, j)
+       a := ai.(*array.Float16)
+       b := aj.(*array.Float16)
+       if c.base.validityNulls {
+               if v, stop := compareKeyedNulls(a.IsNull(li), b.IsNull(lj), 
key); stop {
+                       return v
+               }
+       }
+       return compareOrdered(key.Order, totalOrderKey16(a.Value(li).Uint16()), 
totalOrderKey16(b.Value(lj).Uint16()))
+}
+
+type physicalSortFloat32TotalOrderColumn struct {
+       physicalSortFloatTotalOrderBase
+}
+
+func (c *physicalSortFloat32TotalOrderColumn) compareRowsForKey(i, j uint64, 
key SortKey) int {
+       ai, aj, li, lj := c.base.pair(i, j)
+       a := ai.(*array.Float32)
+       b := aj.(*array.Float32)
+       if c.base.validityNulls {
+               if v, stop := compareKeyedNulls(a.IsNull(li), b.IsNull(lj), 
key); stop {
+                       return v
+               }
+       }
+       return compareOrdered(key.Order, 
totalOrderKey32(math.Float32bits(a.Value(li))), 
totalOrderKey32(math.Float32bits(b.Value(lj))))
+}
+
+type physicalSortFloat64TotalOrderColumn struct {
+       physicalSortFloatTotalOrderBase
+}
+
+func (c *physicalSortFloat64TotalOrderColumn) compareRowsForKey(i, j uint64, 
key SortKey) int {
+       ai, aj, li, lj := c.base.pair(i, j)
+       a := ai.(*array.Float64)
+       b := aj.(*array.Float64)
+       if c.base.validityNulls {
+               if v, stop := compareKeyedNulls(a.IsNull(li), b.IsNull(lj), 
key); stop {
+                       return v
+               }
+       }
+       return compareOrdered(key.Order, 
totalOrderKey64(math.Float64bits(a.Value(li))), 
totalOrderKey64(math.Float64bits(b.Value(lj))))
+}
+
 type physicalSortDecimal32Column struct{ base physicalColumnBase }
 
 func newPhysicalSortDecimal32Column(chunks []arrow.Array, numRows int, vn 
bool, rowMap logicalRowMap) *physicalSortDecimal32Column {
diff --git a/arrow/compute/internal/kernels/vector_sort_radix.go 
b/arrow/compute/internal/kernels/vector_sort_radix.go
index 579f2512..7e313865 100644
--- a/arrow/compute/internal/kernels/vector_sort_radix.go
+++ b/arrow/compute/internal/kernels/vector_sort_radix.go
@@ -19,9 +19,13 @@
 package kernels
 
 import (
+       "math"
+       "unsafe"
+
        "github.com/apache/arrow-go/v18/arrow"
        "github.com/apache/arrow-go/v18/arrow/array"
        "github.com/apache/arrow-go/v18/arrow/compute/exec"
+       "github.com/apache/arrow-go/v18/arrow/float16"
 )
 
 const radixSortMinRows = 2048
@@ -69,6 +73,41 @@ func sortIndicesSingleIntRadix(ctx *exec.KernelCtx, arr 
arrow.Array, indices []u
        return true
 }
 
+// sortIndicesSingleFloatTotalOrderRadix is the SortKey.FloatTotalOrder 
counterpart of
+// sortIndicesSingleIntRadix for a single chunk of non-null floats. The IEEE 
754
+// totalOrder maps each float to a signed integer key, so the floats are radix
+// sorted through a materialized key buffer.
+func sortIndicesSingleFloatTotalOrderRadix(ctx *exec.KernelCtx, arr 
arrow.Array, indices []uint64, order SortOrder) bool {
+       switch values := arr.(type) {
+       case *array.Float16:
+               radixSortFloatTotalOrder(ctx, indices, values.Values(), 2, 
1<<15, order,
+                       func(v float16.Num) int16 { return 
totalOrderKey16(v.Uint16()) })
+       case *array.Float32:
+               radixSortFloatTotalOrder(ctx, indices, values.Float32Values(), 
4, 1<<31, order,
+                       func(v float32) int32 { return 
totalOrderKey32(math.Float32bits(v)) })
+       case *array.Float64:
+               radixSortFloatTotalOrder(ctx, indices, values.Float64Values(), 
8, 1<<63, order,
+                       func(v float64) int64 { return 
totalOrderKey64(math.Float64bits(v)) })
+       default:
+               return false
+       }
+       return true
+}
+
+func radixSortFloatTotalOrder[F any, K int16 | int32 | int64](ctx 
*exec.KernelCtx, indices []uint64, values []F, numBytes int, signBit uint64, 
order SortOrder, toKey func(F) K) {
+       if len(values) < 2 {
+               return
+       }
+       var zero K
+       keyBuf := ctx.Allocate(len(values) * int(unsafe.Sizeof(zero)))
+       defer keyBuf.Release()
+       keys := arrow.GetData[K](keyBuf.Buf())[:len(values)]
+       for i, v := range values {
+               keys[i] = toKey(v)
+       }
+       radixSortIntegerIndices(ctx, indices, keys, numBytes, signBit, order)
+}
+
 // radixSortIntegerIndices performs a stable LSD radix sort over the logical 
row
 // indices. Flipping the sign bit orders signed integers correctly; inverting 
the
 // key reverses the sort order without reversing equal-value rows.
diff --git a/arrow/compute/internal/kernels/vector_sort_radix_test.go 
b/arrow/compute/internal/kernels/vector_sort_radix_test.go
index c0388e67..47d2d3ce 100644
--- a/arrow/compute/internal/kernels/vector_sort_radix_test.go
+++ b/arrow/compute/internal/kernels/vector_sort_radix_test.go
@@ -22,12 +22,14 @@ import (
        "cmp"
        "context"
        "fmt"
+       "math"
        "slices"
        "testing"
 
        "github.com/apache/arrow-go/v18/arrow"
        "github.com/apache/arrow-go/v18/arrow/array"
        "github.com/apache/arrow-go/v18/arrow/compute/exec"
+       "github.com/apache/arrow-go/v18/arrow/float16"
        "github.com/apache/arrow-go/v18/arrow/memory"
 )
 
@@ -147,3 +149,200 @@ func TestSortIndicesSingleChunkRadixSliced(t *testing.T) {
                })
        }
 }
+
+// specTotalOrder is an independent reference for IEEE 754 totalOrder on 
non-payload NaNs:
+// -NaN < -Inf < negatives < -0 < +0 < positives < +Inf < +NaN.
+func specTotalOrder(a, b float64) int {
+       rank := func(v float64) int {
+               switch {
+               case math.IsNaN(v) && math.Signbit(v):
+                       return 0
+               case math.IsNaN(v):
+                       return 2
+               }
+               return 1
+       }
+       if c := cmp.Compare(rank(a), rank(b)); c != 0 || rank(a) != 1 {
+               return c
+       }
+       if c := cmp.Compare(a, b); c != 0 {
+               return c
+       }
+       // Equal numerically: only -0 vs +0 can still differ.
+       return cmp.Compare(boolInt(!math.Signbit(a)), boolInt(!math.Signbit(b)))
+}
+
+func boolInt(b bool) int {
+       if b {
+               return 1
+       }
+       return 0
+}
+
+// TestSortIndicesSingleChunkRadixFloatTotalOrder covers the radix fast path 
(single sliced
+// chunk, no nulls, >= radixSortMinRows) for SortKey.FloatTotalOrder against 
the reference order.
+func TestSortIndicesSingleChunkRadixFloatTotalOrder(t *testing.T) {
+       const start, n = 37, 4096
+       special := []float64{math.NaN(), 
math.Float64frombits(0xFFF8000000000000), math.Inf(1), math.Inf(-1),
+               0, math.Copysign(0, -1), math.SmallestNonzeroFloat64, 
-math.SmallestNonzeroFloat64, math.MaxFloat64, -math.MaxFloat64}
+       values := make([]float64, start+n+19)
+       for i := range values {
+               x := uint64(i)*6364136223846793005 + 1442695040888963407
+               if x%5 == 0 {
+                       values[i] = special[int(x>>33)%len(special)]
+               } else {
+                       values[i] = float64(int64(x>>40)%2001-1000) / 8
+               }
+       }
+
+       for _, dt := range []arrow.DataType{arrow.PrimitiveTypes.Float64, 
arrow.PrimitiveTypes.Float32, arrow.FixedWidthTypes.Float16} {
+               mem := memory.NewCheckedAllocator(memory.NewGoAllocator())
+               ctx := &exec.KernelCtx{Ctx: 
exec.WithAllocator(context.Background(), mem)}
+               bldr := array.NewBuilder(mem, dt)
+               // Values as stored in the column type (narrowing keeps NaN 
signs, may round magnitudes).
+               stored := make([]float64, len(values))
+               for i, v := range values {
+                       switch b := bldr.(type) {
+                       case *array.Float64Builder:
+                               b.Append(v)
+                               stored[i] = v
+                       case *array.Float32Builder:
+                               b.Append(float32(v))
+                               stored[i] = float64(float32(v))
+                       case *array.Float16Builder:
+                               h := float16.New(float32(v))
+                               b.Append(h)
+                               stored[i] = float64(h.Float32())
+                       }
+               }
+               full := bldr.NewArray()
+               bldr.Release()
+               sliced := array.NewSlice(full, start, start+n)
+               col := arrow.NewChunked(dt, []arrow.Array{sliced})
+
+               for _, order := range []SortOrder{Ascending, Descending} {
+                       t.Run(fmt.Sprintf("%s/%v", dt, order), func(t 
*testing.T) {
+                               result, err := SortIndices(ctx, 
[]*arrow.Chunked{col}, []SortKey{{Order: order, FloatTotalOrder: true}})
+                               if err != nil {
+                                       t.Fatal(err)
+                               }
+                               defer result.Release()
+                               got := 
arrow.GetData[uint64](result.Buffers[1].Buf)[:n]
+                               want := make([]uint64, n)
+                               for i := range want {
+                                       want[i] = uint64(i)
+                               }
+                               slices.SortStableFunc(want, func(a, b uint64) 
int {
+                                       c := 
specTotalOrder(stored[start+int(a)], stored[start+int(b)])
+                                       if order == Descending {
+                                               return -c
+                                       }
+                                       return c
+                               })
+                               if !slices.Equal(got, want) {
+                                       t.Fatalf("radix total-order sort 
differs from reference stable sort")
+                               }
+                       })
+               }
+               col.Release()
+               sliced.Release()
+               full.Release()
+               mem.AssertSize(t, 0)
+       }
+}
+
+// totalOrderBitPatterns lists raw bit patterns in ascending IEEE 754 
totalOrder for a float
+// format with the given exponent and mantissa widths. It covers NaN payloads 
and
+// signaling vs quiet NaNs: for positive NaNs, signaling < quiet and larger 
payloads sort
+// later; negative NaNs mirror that.
+func totalOrderBitPatterns(expBits, mantBits uint) []uint64 {
+       sign := uint64(1) << (expBits + mantBits)
+       exp := ((uint64(1) << expBits) - 1) << mantBits
+       quiet := uint64(1) << (mantBits - 1)
+       one := (((uint64(1) << (expBits - 1)) - 1) << mantBits) // 1.0
+       pos := []uint64{
+               0,                    // +0
+               1,                    // smallest subnormal
+               one,                  // 1.0
+               exp - 1,              // largest finite
+               exp,                  // +Inf
+               exp | 1,              // sNaN, payload 1
+               exp | 2,              // sNaN, payload 2
+               exp | quiet,          // canonical qNaN
+               exp | quiet | 1,      // qNaN, payload 1
+               exp | (quiet<<1 - 1), // qNaN, all mantissa bits set
+       }
+       out := make([]uint64, 0, 2*len(pos))
+       for i := len(pos) - 1; i >= 0; i-- {
+               out = append(out, pos[i]|sign) // negatives, most negative first
+       }
+       return append(out, pos...)
+}
+
+// TestSortIndicesFloatTotalOrderNaNPayloads checks NaN payload and 
signaling/quiet ordering
+// with raw bit patterns, on both the comparator path (below radixSortMinRows) 
and the
+// radix fast path.
+func TestSortIndicesFloatTotalOrderNaNPayloads(t *testing.T) {
+       formats := []struct {
+               dt                arrow.DataType
+               expBits, mantBits uint
+       }{
+               {arrow.PrimitiveTypes.Float64, 11, 52},
+               {arrow.PrimitiveTypes.Float32, 8, 23},
+               {arrow.FixedWidthTypes.Float16, 5, 10},
+       }
+       for _, f := range formats {
+               sorted := totalOrderBitPatterns(f.expBits, f.mantBits)
+               for _, reps := range []int{1, radixSortMinRows/len(sorted) + 1} 
{
+                       for _, order := range []SortOrder{Ascending, 
Descending} {
+                               t.Run(fmt.Sprintf("%s/reps=%d/%v", f.dt, reps, 
order), func(t *testing.T) {
+                                       mem := 
memory.NewCheckedAllocator(memory.NewGoAllocator())
+                                       defer mem.AssertSize(t, 0)
+                                       ctx := &exec.KernelCtx{Ctx: 
exec.WithAllocator(context.Background(), mem)}
+
+                                       // Deterministically shuffled input, 
repeated reps times.
+                                       bits := make([]uint64, 0, 
len(sorted)*reps)
+                                       for r := range reps {
+                                               for i := range sorted {
+                                                       bits = append(bits, 
sorted[(i*7+r)%len(sorted)])
+                                               }
+                                       }
+                                       bldr := array.NewBuilder(mem, f.dt)
+                                       for _, b := range bits {
+                                               switch bb := bldr.(type) {
+                                               case *array.Float64Builder:
+                                                       
bb.Append(math.Float64frombits(b))
+                                               case *array.Float32Builder:
+                                                       
bb.Append(math.Float32frombits(uint32(b)))
+                                               case *array.Float16Builder:
+                                                       
bb.Append(float16.FromBits(uint16(b)))
+                                               }
+                                       }
+                                       arr := bldr.NewArray()
+                                       bldr.Release()
+                                       defer arr.Release()
+                                       col := arrow.NewChunked(f.dt, 
[]arrow.Array{arr})
+                                       defer col.Release()
+
+                                       result, err := SortIndices(ctx, 
[]*arrow.Chunked{col}, []SortKey{{Order: order, FloatTotalOrder: true}})
+                                       if err != nil {
+                                               t.Fatal(err)
+                                       }
+                                       defer result.Release()
+                                       got := 
arrow.GetData[uint64](result.Buffers[1].Buf)[:len(bits)]
+
+                                       rank := make(map[uint64]int, 
len(sorted))
+                                       for i, b := range sorted {
+                                               rank[b] = i
+                                       }
+                                       for k := 1; k < len(got); k++ {
+                                               prev, cur := 
rank[bits[got[k-1]]], rank[bits[got[k]]]
+                                               if (order == Ascending && prev 
> cur) || (order == Descending && prev < cur) {
+                                                       t.Fatalf("position %d: 
bits %#x sorted before %#x", k, bits[got[k-1]], bits[got[k]])
+                                               }
+                                       }
+                               })
+                       }
+               }
+       }
+}
diff --git a/arrow/compute/internal/kernels/vector_sort_support.go 
b/arrow/compute/internal/kernels/vector_sort_support.go
index b762ea9a..c08471a6 100644
--- a/arrow/compute/internal/kernels/vector_sort_support.go
+++ b/arrow/compute/internal/kernels/vector_sort_support.go
@@ -136,6 +136,25 @@ func compareFloatNaNs(order SortOrder, viNaN, vjNaN bool) 
(int, bool) {
        return 0, false
 }
 
+// The totalOrderKey helpers map the raw bits of an IEEE 754 float to a signed 
integer whose
+// natural order is the IEEE 754 totalOrder of the floats. Non-negative floats 
(sign bit clear)
+// already order correctly as signed integers; for negative floats the 
magnitude bits are
+// inverted so larger magnitudes sort first. Arithmetic shift of the sign 
yields the mask.
+func totalOrderKey16(bits uint16) int16 {
+       s := int16(bits)
+       return s ^ int16(uint16(s>>15)>>1)
+}
+
+func totalOrderKey32(bits uint32) int32 {
+       s := int32(bits)
+       return s ^ int32(uint32(s>>31)>>1)
+}
+
+func totalOrderKey64(bits uint64) int64 {
+       s := int64(bits)
+       return s ^ int64(uint64(s>>63)>>1)
+}
+
 func sortFloat64[T float16.Num | float32 | float64](v T) float64 {
        switch x := any(v).(type) {
        case float16.Num:
diff --git a/arrow/compute/vector_sort.go b/arrow/compute/vector_sort.go
index bad14e4f..cc29c1ca 100644
--- a/arrow/compute/vector_sort.go
+++ b/arrow/compute/vector_sort.go
@@ -88,7 +88,8 @@ const (
        SortNullsAtStart    = kernels.NullsAtStart
 )
 
-// SortKey defines a column to sort by with its ordering and null placement 
options.
+// SortKey defines a column to sort by with its ordering, null placement and 
float ordering options.
+// See kernels.SortKey.FloatTotalOrder for IEEE 754 totalOrder sorting of 
float columns.
 type SortKey = kernels.SortKey
 
 // SortOptions defines the desired sort order for the input.
diff --git a/arrow/compute/vector_sort_test.go 
b/arrow/compute/vector_sort_test.go
index a4922baa..5714cd03 100644
--- a/arrow/compute/vector_sort_test.go
+++ b/arrow/compute/vector_sort_test.go
@@ -20,7 +20,10 @@ package compute_test
 
 import (
        "context"
+       "fmt"
        "math"
+       "slices"
+       "strconv"
        "strings"
        "testing"
 
@@ -1882,3 +1885,167 @@ func TestSortIndicesUUIDLexicographic(t *testing.T) {
                {Order: kernels.Descending, NullPlacement: 
kernels.NullsAtStart},
        }, []uint64{1, 0, 3, 2})
 }
+
+// floatTotalOrderLabels is the ascending IEEE 754 totalOrder of 
floatTotalOrderInput's non-null
+// values; the input deliberately interleaves +0 and -0 so ties would keep 
input order.
+var floatTotalOrderLabels = []string{"-NaN", "-Inf", "-2", "-0", "-0", "0", 
"0", "1", "+Inf", "NaN"}
+
+func floatTotalOrderInput() []float64 {
+       negNaN := math.Float64frombits(0xFFF8000000000000)
+       negZero := math.Copysign(0, -1)
+       return []float64{1, math.NaN(), 0, math.Inf(-1), -2, negNaN, negZero, 
math.Inf(1), 0, negZero}
+}
+
+func floatLabel(v float64) string {
+       switch {
+       case math.IsNaN(v) && math.Signbit(v):
+               return "-NaN"
+       case math.IsNaN(v):
+               return "NaN"
+       case math.IsInf(v, 1):
+               return "+Inf"
+       case math.IsInf(v, -1):
+               return "-Inf"
+       case v == 0 && math.Signbit(v):
+               return "-0"
+       }
+       return strconv.FormatFloat(v, 'g', -1, 64)
+}
+
+func expectedTotalOrderLabels(key compute.SortKey) []string {
+       vals := slices.Clone(floatTotalOrderLabels)
+       if key.Order == compute.SortOrderDescending {
+               slices.Reverse(vals)
+       }
+       if key.NullPlacement == compute.SortNullsAtStart {
+               return append([]string{"null"}, vals...)
+       }
+       return append(vals, "null")
+}
+
+// buildFloatTotalOrderArray builds the input (plus one null in the middle) as 
the given float type.
+func buildFloatTotalOrderArray(t *testing.T, mem memory.Allocator, dt 
arrow.DataType, vals []float64) arrow.Array {
+       t.Helper()
+       bldr := array.NewBuilder(mem, dt)
+       defer bldr.Release()
+       for i, v := range vals {
+               if i == len(vals)/2 {
+                       bldr.AppendNull()
+               }
+               switch b := bldr.(type) {
+               case *array.Float64Builder:
+                       b.Append(v)
+               case *array.Float32Builder:
+                       b.Append(float32(v))
+               case *array.Float16Builder:
+                       // float16.New goes through float32, which keeps the 
NaN sign bit.
+                       b.Append(float16.New(float32(v)))
+               }
+       }
+       return bldr.NewArray()
+}
+
+func labelsAt(arr arrow.Array, indices []uint64) []string {
+       out := make([]string, len(indices))
+       for k, i := range indices {
+               if arr.IsNull(int(i)) {
+                       out[k] = "null"
+                       continue
+               }
+               switch a := arr.(type) {
+               case *array.Float64:
+                       out[k] = floatLabel(a.Value(int(i)))
+               case *array.Float32:
+                       out[k] = floatLabel(float64(a.Value(int(i))))
+               case *array.Float16:
+                       out[k] = floatLabel(float64(a.Value(int(i)).Float32()))
+               }
+       }
+       return out
+}
+
+func floatTotalOrderKeys() []compute.SortKey {
+       var keys []compute.SortKey
+       for _, o := range []kernels.SortOrder{compute.SortOrderAscending, 
compute.SortOrderDescending} {
+               for _, np := range 
[]kernels.NullPlacement{compute.SortNullsAtEnd, compute.SortNullsAtStart} {
+                       keys = append(keys, compute.SortKey{Order: o, 
NullPlacement: np, FloatTotalOrder: true})
+               }
+       }
+       return keys
+}
+
+func TestSortIndicesFloatTotalOrder(t *testing.T) {
+       mem := memory.NewGoAllocator()
+       ctx := context.Background()
+       vals := floatTotalOrderInput()
+
+       for _, dt := range []arrow.DataType{arrow.PrimitiveTypes.Float64, 
arrow.PrimitiveTypes.Float32, arrow.FixedWidthTypes.Float16} {
+               for _, key := range floatTotalOrderKeys() {
+                       want := expectedTotalOrderLabels(key)
+                       name := fmt.Sprintf("%s/order=%d/nulls=%d", dt, 
key.Order, key.NullPlacement)
+
+                       t.Run(name+"/array", func(t *testing.T) {
+                               arr := buildFloatTotalOrderArray(t, mem, dt, 
vals)
+                               defer arr.Release()
+                               idx, err := compute.SortIndicesArray(ctx, arr, 
key)
+                               require.NoError(t, err)
+                               defer idx.Release()
+                               assert.Equal(t, want, labelsAt(arr, 
idx.(*array.Uint64).Uint64Values()))
+                       })
+
+                       t.Run(name+"/chunked", func(t *testing.T) {
+                               // Two chunks exercise the per-chunk sort 
followed by the merge.
+                               a := buildFloatTotalOrderArray(t, mem, dt, 
vals[:5])
+                               defer a.Release()
+                               b := buildFloatTotalOrderArray(t, mem, dt, 
vals[5:])
+                               defer b.Release()
+                               chunked := arrow.NewChunked(dt, 
[]arrow.Array{a, b})
+                               defer chunked.Release()
+                               flat, err := 
array.Concatenate(chunked.Chunks(), mem)
+                               require.NoError(t, err)
+                               defer flat.Release()
+                               idx, err := compute.SortIndicesChunked(ctx, 
chunked, key)
+                               require.NoError(t, err)
+                               defer idx.Release()
+                               got := labelsAt(flat, 
idx.(*array.Uint64).Uint64Values())
+                               // One null per chunk.
+                               if key.NullPlacement == 
compute.SortNullsAtStart {
+                                       assert.Equal(t, 
append([]string{"null"}, want...), got)
+                               } else {
+                                       assert.Equal(t, 
append(slices.Clone(want), "null"), got)
+                               }
+                       })
+
+                       t.Run(name+"/record_batch", func(t *testing.T) {
+                               // Leading float key with a constant secondary 
key exercises the multi-key radix path.
+                               arr := buildFloatTotalOrderArray(t, mem, dt, 
vals)
+                               defer arr.Release()
+                               ib := array.NewInt32Builder(mem)
+                               defer ib.Release()
+                               ib.AppendValues(make([]int32, arr.Len()), nil)
+                               ints := ib.NewArray()
+                               defer ints.Release()
+                               schema := arrow.NewSchema([]arrow.Field{{Name: 
"f", Type: dt, Nullable: true}, {Name: "i", Type: arrow.PrimitiveTypes.Int32}}, 
nil)
+                               rec := array.NewRecordBatch(schema, 
[]arrow.Array{arr, ints}, int64(arr.Len()))
+                               defer rec.Release()
+                               k1 := compute.SortKey{ColumnIndex: 1}
+                               idx, err := compute.SortIndicesRecordBatch(ctx, 
rec, []compute.SortKey{key, k1})
+                               require.NoError(t, err)
+                               defer idx.Release()
+                               assert.Equal(t, want, labelsAt(arr, 
idx.(*array.Uint64).Uint64Values()))
+                       })
+               }
+       }
+}
+
+func TestSortIndicesFloatDefaultOrderingUnchanged(t *testing.T) {
+       // The default ordering keeps NaNs null-like, ignores their sign, and 
treats -0 == +0.
+       mem := memory.NewGoAllocator()
+       arr := buildFloatTotalOrderArray(t, mem, arrow.PrimitiveTypes.Float64, 
floatTotalOrderInput())
+       defer arr.Release()
+       idx, err := compute.SortIndicesArray(context.Background(), arr, 
compute.SortKey{Order: compute.SortOrderDescending, NullPlacement: 
compute.SortNullsAtStart})
+       require.NoError(t, err)
+       defer idx.Release()
+       assert.Equal(t, []string{"null", "NaN", "-NaN", "+Inf", "1", "0", "-0", 
"0", "-0", "-2", "-Inf"},
+               labelsAt(arr, idx.(*array.Uint64).Uint64Values()))
+}

Reply via email to