Files
wehub-resource-sync 498b235461
Build and test / Build and test AMD64 Ubuntu 22.04 (push) Failing after 0s
Publish Builder / amazonlinux2023 (push) Failing after 1s
Build and test / UT for Go (push) Has been skipped
Publish KRTE Images / KRTE (push) Failing after 1s
Build and test / Integration Test (push) Has been skipped
Build and test / Upload Code Coverage (push) Has been skipped
Publish Builder / rockylinux9 (push) Failing after 1s
Publish Builder / ubuntu22.04 (push) Failing after 0s
Publish Builder / ubuntu24.04 (push) Failing after 0s
Publish Gpu Builder / publish-gpu-builder (push) Failing after 1s
Publish Test Images / PyTest (push) Failing after 0s
Build and test / UT for Cpp (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:17 +08:00

1819 lines
58 KiB
Go

/*
* # Licensed to the LF AI & Data foundation under one
* # or more contributor license agreements. See the NOTICE file
* # distributed with this work for additional information
* # regarding copyright ownership. The ASF licenses this file
* # to you under the Apache License, Version 2.0 (the
* # "License"); you may not use this file except in compliance
* # with the License. You may obtain a copy of the License at
* #
* # http://www.apache.org/licenses/LICENSE-2.0
* #
* # Unless required by applicable law or agreed to in writing, software
* # distributed under the License is distributed on an "AS IS" BASIS,
* # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* # See the License for the specific language governing permissions and
* # limitations under the License.
*/
package chain
import (
"context"
"fmt"
"testing"
"github.com/apache/arrow/go/v17/arrow"
"github.com/apache/arrow/go/v17/arrow/array"
"github.com/apache/arrow/go/v17/arrow/memory"
"github.com/stretchr/testify/suite"
"github.com/milvus-io/milvus/internal/util/function/chain/types"
)
// MergeHelperTestSuite tests internal helper functions of the merge operator
// that are not covered by the main MergeOpTestSuite in chain_test.go.
type MergeHelperTestSuite struct {
suite.Suite
pool *memory.CheckedAllocator
}
func (s *MergeHelperTestSuite) SetupTest() {
s.pool = memory.NewCheckedAllocator(memory.NewGoAllocator())
}
func (s *MergeHelperTestSuite) TearDownTest() {
s.pool.AssertSize(s.T(), 0)
}
func TestMergeHelperTestSuite(t *testing.T) {
suite.Run(t, new(MergeHelperTestSuite))
}
// helper to create a simple DF with $id (int64), $score (float32), and optionally a field column
func (s *MergeHelperTestSuite) createDF(ids []int64, scores []float32, chunkSizes []int64) *DataFrame {
builder := NewDataFrameBuilder()
builder.SetChunkSizes(chunkSizes)
offset := 0
idChunks := make([]arrow.Array, len(chunkSizes))
scoreChunks := make([]arrow.Array, len(chunkSizes))
for i, size := range chunkSizes {
idBuilder := array.NewInt64Builder(s.pool)
scoreBuilder := array.NewFloat32Builder(s.pool)
for j := 0; j < int(size); j++ {
idBuilder.Append(ids[offset+j])
scoreBuilder.Append(scores[offset+j])
}
idChunks[i] = idBuilder.NewArray()
idBuilder.Release()
scoreChunks[i] = scoreBuilder.NewArray()
scoreBuilder.Release()
offset += int(size)
}
err := builder.AddColumnFromChunks(types.IDFieldName, idChunks)
s.Require().NoError(err)
err = builder.AddColumnFromChunks(types.ScoreFieldName, scoreChunks)
s.Require().NoError(err)
return builder.Build()
}
func (s *MergeHelperTestSuite) createDFWithField(ids []int64, scores []float32, fieldName string, fieldValues []string, chunkSizes []int64) *DataFrame {
builder := NewDataFrameBuilder()
builder.SetChunkSizes(chunkSizes)
offset := 0
idChunks := make([]arrow.Array, len(chunkSizes))
scoreChunks := make([]arrow.Array, len(chunkSizes))
fieldChunks := make([]arrow.Array, len(chunkSizes))
for i, size := range chunkSizes {
idB := array.NewInt64Builder(s.pool)
scoreB := array.NewFloat32Builder(s.pool)
fieldB := array.NewStringBuilder(s.pool)
for j := 0; j < int(size); j++ {
idB.Append(ids[offset+j])
scoreB.Append(scores[offset+j])
fieldB.Append(fieldValues[offset+j])
}
idChunks[i] = idB.NewArray()
idB.Release()
scoreChunks[i] = scoreB.NewArray()
scoreB.Release()
fieldChunks[i] = fieldB.NewArray()
fieldB.Release()
offset += int(size)
}
err := builder.AddColumnFromChunks(types.IDFieldName, idChunks)
s.Require().NoError(err)
err = builder.AddColumnFromChunks(types.ScoreFieldName, scoreChunks)
s.Require().NoError(err)
err = builder.AddColumnFromChunks(fieldName, fieldChunks)
s.Require().NoError(err)
return builder.Build()
}
// =============================================================================
// getIDValue Tests
// =============================================================================
func (s *MergeHelperTestSuite) TestGetIDValueInt64() {
b := array.NewInt64Builder(s.pool)
b.Append(42)
b.AppendNull()
arr := b.NewArray()
b.Release()
defer arr.Release()
s.Equal(int64(42), getIDValue(arr, 0))
s.Nil(getIDValue(arr, 1)) // null
}
func (s *MergeHelperTestSuite) TestGetIDValueString() {
b := array.NewStringBuilder(s.pool)
b.Append("hello")
arr := b.NewArray()
b.Release()
defer arr.Release()
s.Equal("hello", getIDValue(arr, 0))
}
func (s *MergeHelperTestSuite) TestGetIDValueUnsupported() {
b := array.NewFloat32Builder(s.pool)
b.Append(1.0)
arr := b.NewArray()
b.Release()
defer arr.Release()
s.Nil(getIDValue(arr, 0))
}
// =============================================================================
// compareIDs Tests
// =============================================================================
func (s *MergeHelperTestSuite) TestCompareIDs() {
// Int64 comparisons
s.Equal(-1, compareIDs(int64(1), int64(2)))
s.Equal(0, compareIDs(int64(1), int64(1)))
s.Equal(1, compareIDs(int64(2), int64(1)))
// String comparisons
s.Equal(-1, compareIDs("a", "b"))
s.Equal(0, compareIDs("a", "a"))
s.Equal(1, compareIDs("b", "a"))
// Mixed types return 0
s.Equal(0, compareIDs(int64(1), "1"))
s.Equal(0, compareIDs("1", int64(1)))
// Unsupported type
s.Equal(0, compareIDs(1.0, 2.0))
}
// =============================================================================
// collectOrderedFieldNames Tests
// =============================================================================
func (s *MergeHelperTestSuite) TestCollectOrderedFieldNames() {
df1 := s.createDFWithField(
[]int64{1}, []float32{0.9},
"field_a", []string{"val"},
[]int64{1},
)
df2 := s.createDFWithField(
[]int64{2}, []float32{0.8},
"field_b", []string{"val"},
[]int64{1},
)
defer df1.Release()
defer df2.Release()
names := collectOrderedFieldNames([]*DataFrame{df1, df2})
s.Equal([]string{"field_a", "field_b"}, names)
}
func (s *MergeHelperTestSuite) TestCollectOrderedFieldNamesNoFields() {
df1 := s.createDF([]int64{1}, []float32{0.9}, []int64{1})
defer df1.Release()
names := collectOrderedFieldNames([]*DataFrame{df1})
s.Empty(names)
}
func (s *MergeHelperTestSuite) TestCollectOrderedFieldNamesDedup() {
df1 := s.createDFWithField([]int64{1}, []float32{0.9}, "name", []string{"a"}, []int64{1})
df2 := s.createDFWithField([]int64{2}, []float32{0.8}, "name", []string{"b"}, []int64{1})
defer df1.Release()
defer df2.Release()
names := collectOrderedFieldNames([]*DataFrame{df1, df2})
s.Equal([]string{"name"}, names) // deduplicated
}
// =============================================================================
// sortAndExtractResults Tests
// =============================================================================
func (s *MergeHelperTestSuite) TestSortAndExtractResultsDescending() {
idScores := map[any]float32{
int64(1): 0.5,
int64(2): 0.9,
int64(3): 0.7,
}
idLocs := map[any]idLocation{
int64(1): {inputIdx: 0, rowIdx: 0},
int64(2): {inputIdx: 0, rowIdx: 1},
int64(3): {inputIdx: 1, rowIdx: 0},
}
ids, scores, locs := sortAndExtractResults(idScores, idLocs, true)
s.Equal(3, len(ids))
// Descending: 0.9, 0.7, 0.5
s.Equal(int64(2), ids[0])
s.InDelta(0.9, float64(scores[0]), 1e-6)
s.Equal(int64(3), ids[1])
s.InDelta(0.7, float64(scores[1]), 1e-6)
s.Equal(int64(1), ids[2])
s.InDelta(0.5, float64(scores[2]), 1e-6)
s.Equal(0, locs[0].inputIdx)
}
func (s *MergeHelperTestSuite) TestSortAndExtractResultsAscending() {
idScores := map[any]float32{
int64(1): 0.5,
int64(2): 0.9,
}
idLocs := map[any]idLocation{
int64(1): {inputIdx: 0, rowIdx: 0},
int64(2): {inputIdx: 0, rowIdx: 1},
}
ids, scores, _ := sortAndExtractResults(idScores, idLocs, false)
s.Equal(int64(1), ids[0])
s.InDelta(0.5, float64(scores[0]), 1e-6)
s.Equal(int64(2), ids[1])
s.InDelta(0.9, float64(scores[1]), 1e-6)
}
func (s *MergeHelperTestSuite) TestSortAndExtractResultsTieBreaking() {
// Same score, should be sorted by ID ascending
idScores := map[any]float32{
int64(3): 0.5,
int64(1): 0.5,
int64(2): 0.5,
}
idLocs := map[any]idLocation{
int64(3): {inputIdx: 0, rowIdx: 2},
int64(1): {inputIdx: 0, rowIdx: 0},
int64(2): {inputIdx: 0, rowIdx: 1},
}
ids, _, _ := sortAndExtractResults(idScores, idLocs, true)
s.Equal(int64(1), ids[0])
s.Equal(int64(2), ids[1])
s.Equal(int64(3), ids[2])
}
// =============================================================================
// buildEmptyArray Tests
// =============================================================================
func (s *MergeHelperTestSuite) TestBuildEmptyArrayAllTypes() {
dtypes := []arrow.DataType{
arrow.FixedWidthTypes.Boolean,
arrow.PrimitiveTypes.Int8,
arrow.PrimitiveTypes.Int16,
arrow.PrimitiveTypes.Int32,
arrow.PrimitiveTypes.Int64,
arrow.PrimitiveTypes.Float32,
arrow.PrimitiveTypes.Float64,
arrow.BinaryTypes.String,
}
for _, dt := range dtypes {
arr, err := buildEmptyArray(s.pool, dt)
s.Require().NoError(err, "type: %s", dt.Name())
s.Equal(0, arr.Len(), "type: %s", dt.Name())
arr.Release()
}
}
func (s *MergeHelperTestSuite) TestBuildEmptyArrayUnsupportedType() {
_, err := buildEmptyArray(s.pool, arrow.BinaryTypes.Binary)
s.Error(err)
s.Contains(err.Error(), "unsupported type")
}
// =============================================================================
// getArrayValue Tests
// =============================================================================
func (s *MergeHelperTestSuite) TestGetArrayValueTypes() {
testCases := []struct {
name string
build func() arrow.Array
check func(any)
}{
{
name: "bool",
build: func() arrow.Array {
b := array.NewBooleanBuilder(s.pool)
b.Append(true)
arr := b.NewArray()
b.Release()
return arr
},
check: func(v any) { s.Equal(true, v) },
},
{
name: "int8",
build: func() arrow.Array {
b := array.NewInt8Builder(s.pool)
b.Append(7)
arr := b.NewArray()
b.Release()
return arr
},
check: func(v any) { s.Equal(int8(7), v) },
},
{
name: "int16",
build: func() arrow.Array {
b := array.NewInt16Builder(s.pool)
b.Append(100)
arr := b.NewArray()
b.Release()
return arr
},
check: func(v any) { s.Equal(int16(100), v) },
},
{
name: "int32",
build: func() arrow.Array {
b := array.NewInt32Builder(s.pool)
b.Append(1000)
arr := b.NewArray()
b.Release()
return arr
},
check: func(v any) { s.Equal(int32(1000), v) },
},
{
name: "int64",
build: func() arrow.Array {
b := array.NewInt64Builder(s.pool)
b.Append(42)
arr := b.NewArray()
b.Release()
return arr
},
check: func(v any) { s.Equal(int64(42), v) },
},
{
name: "uint8",
build: func() arrow.Array {
b := array.NewUint8Builder(s.pool)
b.Append(5)
arr := b.NewArray()
b.Release()
return arr
},
check: func(v any) { s.Equal(uint8(5), v) },
},
{
name: "uint16",
build: func() arrow.Array {
b := array.NewUint16Builder(s.pool)
b.Append(500)
arr := b.NewArray()
b.Release()
return arr
},
check: func(v any) { s.Equal(uint16(500), v) },
},
{
name: "uint32",
build: func() arrow.Array {
b := array.NewUint32Builder(s.pool)
b.Append(5000)
arr := b.NewArray()
b.Release()
return arr
},
check: func(v any) { s.Equal(uint32(5000), v) },
},
{
name: "uint64",
build: func() arrow.Array {
b := array.NewUint64Builder(s.pool)
b.Append(50000)
arr := b.NewArray()
b.Release()
return arr
},
check: func(v any) { s.Equal(uint64(50000), v) },
},
{
name: "float32",
build: func() arrow.Array {
b := array.NewFloat32Builder(s.pool)
b.Append(3.14)
arr := b.NewArray()
b.Release()
return arr
},
check: func(v any) { s.InDelta(3.14, float64(v.(float32)), 1e-5) },
},
{
name: "float64",
build: func() arrow.Array {
b := array.NewFloat64Builder(s.pool)
b.Append(2.718)
arr := b.NewArray()
b.Release()
return arr
},
check: func(v any) { s.InDelta(2.718, v.(float64), 1e-9) },
},
{
name: "string",
build: func() arrow.Array {
b := array.NewStringBuilder(s.pool)
b.Append("hello")
arr := b.NewArray()
b.Release()
return arr
},
check: func(v any) { s.Equal("hello", v) },
},
}
for _, tc := range testCases {
s.Run(tc.name, func() {
arr := tc.build()
defer arr.Release()
val := getArrayValue(arr, 0)
tc.check(val)
})
}
}
func (s *MergeHelperTestSuite) TestGetArrayValueNull() {
b := array.NewInt64Builder(s.pool)
b.AppendNull()
arr := b.NewArray()
b.Release()
defer arr.Release()
s.Nil(getArrayValue(arr, 0))
}
// =============================================================================
// getNormalizeFunc Tests
// =============================================================================
func (s *MergeHelperTestSuite) TestGetNormalizeFuncCosine() {
fn := getNormalizeFunc("COSINE")
s.NotNil(fn)
s.InDelta(0.75, float64(fn(0.5)), 1e-6) // (1+0.5)*0.5
s.InDelta(1.0, float64(fn(1.0)), 1e-6) // (1+1.0)*0.5
s.InDelta(0.0, float64(fn(-1.0)), 1e-6) // (1-1.0)*0.5
s.InDelta(0.5, float64(fn(0.0)), 1e-6) // (1+0)*0.5
}
func (s *MergeHelperTestSuite) TestGetNormalizeFuncIP() {
fn := getNormalizeFunc("IP")
s.NotNil(fn)
s.InDelta(0.5, float64(fn(0.0)), 1e-6) // 0.5 + atan(0)/pi = 0.5
}
func (s *MergeHelperTestSuite) TestGetNormalizeFuncBM25() {
fn := getNormalizeFunc("BM25")
s.NotNil(fn)
s.InDelta(0.0, float64(fn(0.0)), 1e-6) // 2*atan(0)/pi = 0
}
func (s *MergeHelperTestSuite) TestGetNormalizeFuncL2() {
fn := getNormalizeFunc("L2")
s.NotNil(fn)
s.InDelta(1.0, float64(fn(0.0)), 1e-6) // 1 - 2*atan(0)/pi = 1
s.True(fn(1.0) < 1.0) // distance > 0 -> normalized < 1
}
// =============================================================================
// getDirectionConvertFunc Tests
// =============================================================================
func (s *MergeHelperTestSuite) TestGetDirectionConvertFunc() {
s.Nil(getDirectionConvertFunc("COSINE"))
s.Nil(getDirectionConvertFunc("IP"))
s.Nil(getDirectionConvertFunc("BM25"))
fn := getDirectionConvertFunc("L2")
s.NotNil(fn)
s.InDelta(1.0, float64(fn(0.0)), 1e-6)
}
// =============================================================================
// MergeOp Option Tests
// =============================================================================
func (s *MergeHelperTestSuite) TestWithWeightsOption() {
op := NewMergeOp(MergeStrategyWeighted, WithWeights([]float64{0.3, 0.7}))
s.Equal([]float64{0.3, 0.7}, op.weights)
}
func (s *MergeHelperTestSuite) TestWithRRFKOption() {
op := NewMergeOp(MergeStrategyRRF, WithRRFK(30))
s.InDelta(30.0, op.rrfK, 1e-9)
}
func (s *MergeHelperTestSuite) TestWithMetricTypesOption() {
op := NewMergeOp(MergeStrategyRRF, WithMetricTypes([]string{"COSINE", "L2"}))
// Mixed metrics → sortDescending=true, scoreNormFuncs populated for direction conversion
s.True(op.SortDescending())
s.Len(op.scoreNormFuncs, 2)
s.Nil(op.scoreNormFuncs[0]) // COSINE: already larger-is-better, no conversion
s.NotNil(op.scoreNormFuncs[1]) // L2: needs direction conversion
}
func (s *MergeHelperTestSuite) TestWithNormalizeOption() {
op := NewMergeOp(MergeStrategyRRF, WithNormalize(false))
// No metric types + normalize=false → sortDescending=true (default), no normFuncs
s.True(op.SortDescending())
s.Nil(op.scoreNormFuncs)
}
// =============================================================================
// SortDescending Tests
// =============================================================================
func (s *MergeHelperTestSuite) TestSortDescendingWithNormalize() {
op := NewMergeOp(MergeStrategyRRF, WithNormalize(true))
s.True(op.SortDescending())
}
func (s *MergeHelperTestSuite) TestSortDescendingNoNormalizeNoMetrics() {
op := NewMergeOp(MergeStrategyRRF, WithNormalize(false))
s.True(op.SortDescending())
}
func (s *MergeHelperTestSuite) TestSortDescendingNoNormalizeCosine() {
op := NewMergeOp(MergeStrategyRRF, WithNormalize(false), WithMetricTypes([]string{"COSINE"}))
s.True(op.SortDescending())
}
func (s *MergeHelperTestSuite) TestSortDescendingNoNormalizeL2() {
op := NewMergeOp(MergeStrategyRRF, WithNormalize(false), WithMetricTypes([]string{"L2"}))
s.False(op.SortDescending())
}
func (s *MergeHelperTestSuite) TestSortDescendingMixedMetrics() {
op := NewMergeOp(MergeStrategyRRF, WithNormalize(false), WithMetricTypes([]string{"COSINE", "L2"}))
s.True(op.SortDescending()) // mixed -> descending
}
// =============================================================================
// createDFWithTypedField helpers - build DFs with typed field columns
// =============================================================================
func (s *MergeHelperTestSuite) createDFWithBoolField(ids []int64, scores []float32, fieldName string, fieldValues []bool) *DataFrame {
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{int64(len(ids))})
idB := array.NewInt64Builder(s.pool)
scoreB := array.NewFloat32Builder(s.pool)
fieldB := array.NewBooleanBuilder(s.pool)
for i := range ids {
idB.Append(ids[i])
scoreB.Append(scores[i])
fieldB.Append(fieldValues[i])
}
idArr := idB.NewArray()
idB.Release()
scoreArr := scoreB.NewArray()
scoreB.Release()
fieldArr := fieldB.NewArray()
fieldB.Release()
err := builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idArr})
s.Require().NoError(err)
err = builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreArr})
s.Require().NoError(err)
err = builder.AddColumnFromChunks(fieldName, []arrow.Array{fieldArr})
s.Require().NoError(err)
return builder.Build()
}
func (s *MergeHelperTestSuite) createDFWithInt32Field(ids []int64, scores []float32, fieldName string, fieldValues []int32) *DataFrame {
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{int64(len(ids))})
idB := array.NewInt64Builder(s.pool)
scoreB := array.NewFloat32Builder(s.pool)
fieldB := array.NewInt32Builder(s.pool)
for i := range ids {
idB.Append(ids[i])
scoreB.Append(scores[i])
fieldB.Append(fieldValues[i])
}
idArr := idB.NewArray()
idB.Release()
scoreArr := scoreB.NewArray()
scoreB.Release()
fieldArr := fieldB.NewArray()
fieldB.Release()
err := builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idArr})
s.Require().NoError(err)
err = builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreArr})
s.Require().NoError(err)
err = builder.AddColumnFromChunks(fieldName, []arrow.Array{fieldArr})
s.Require().NoError(err)
return builder.Build()
}
func (s *MergeHelperTestSuite) createDFWithFloat64Field(ids []int64, scores []float32, fieldName string, fieldValues []float64) *DataFrame {
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{int64(len(ids))})
idB := array.NewInt64Builder(s.pool)
scoreB := array.NewFloat32Builder(s.pool)
fieldB := array.NewFloat64Builder(s.pool)
for i := range ids {
idB.Append(ids[i])
scoreB.Append(scores[i])
fieldB.Append(fieldValues[i])
}
idArr := idB.NewArray()
idB.Release()
scoreArr := scoreB.NewArray()
scoreB.Release()
fieldArr := fieldB.NewArray()
fieldB.Release()
err := builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idArr})
s.Require().NoError(err)
err = builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreArr})
s.Require().NoError(err)
err = builder.AddColumnFromChunks(fieldName, []arrow.Array{fieldArr})
s.Require().NoError(err)
return builder.Build()
}
func (s *MergeHelperTestSuite) createDFWithFloat32Field(ids []int64, scores []float32, fieldName string, fieldValues []float32) *DataFrame {
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{int64(len(ids))})
idB := array.NewInt64Builder(s.pool)
scoreB := array.NewFloat32Builder(s.pool)
fieldB := array.NewFloat32Builder(s.pool)
for i := range ids {
idB.Append(ids[i])
scoreB.Append(scores[i])
fieldB.Append(fieldValues[i])
}
idArr := idB.NewArray()
idB.Release()
scoreArr := scoreB.NewArray()
scoreB.Release()
fieldArr := fieldB.NewArray()
fieldB.Release()
err := builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idArr})
s.Require().NoError(err)
err = builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreArr})
s.Require().NoError(err)
err = builder.AddColumnFromChunks(fieldName, []arrow.Array{fieldArr})
s.Require().NoError(err)
return builder.Build()
}
func (s *MergeHelperTestSuite) createDFWithInt8Field(ids []int64, scores []float32, fieldName string, fieldValues []int8) *DataFrame {
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{int64(len(ids))})
idB := array.NewInt64Builder(s.pool)
scoreB := array.NewFloat32Builder(s.pool)
fieldB := array.NewInt8Builder(s.pool)
for i := range ids {
idB.Append(ids[i])
scoreB.Append(scores[i])
fieldB.Append(fieldValues[i])
}
idArr := idB.NewArray()
idB.Release()
scoreArr := scoreB.NewArray()
scoreB.Release()
fieldArr := fieldB.NewArray()
fieldB.Release()
err := builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idArr})
s.Require().NoError(err)
err = builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreArr})
s.Require().NoError(err)
err = builder.AddColumnFromChunks(fieldName, []arrow.Array{fieldArr})
s.Require().NoError(err)
return builder.Build()
}
func (s *MergeHelperTestSuite) createDFWithInt16Field(ids []int64, scores []float32, fieldName string, fieldValues []int16) *DataFrame {
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{int64(len(ids))})
idB := array.NewInt64Builder(s.pool)
scoreB := array.NewFloat32Builder(s.pool)
fieldB := array.NewInt16Builder(s.pool)
for i := range ids {
idB.Append(ids[i])
scoreB.Append(scores[i])
fieldB.Append(fieldValues[i])
}
idArr := idB.NewArray()
idB.Release()
scoreArr := scoreB.NewArray()
scoreB.Release()
fieldArr := fieldB.NewArray()
fieldB.Release()
err := builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idArr})
s.Require().NoError(err)
err = builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreArr})
s.Require().NoError(err)
err = builder.AddColumnFromChunks(fieldName, []arrow.Array{fieldArr})
s.Require().NoError(err)
return builder.Build()
}
// =============================================================================
// scoreMergeFunc Tests
// =============================================================================
func (s *MergeHelperTestSuite) TestMaxMergeFunc() {
result, count := maxMergeFunc(0.5, 0.8, 1)
s.InDelta(0.8, float64(result), 1e-6)
s.Equal(2, count)
result, count = maxMergeFunc(0.8, 0.3, 2)
s.InDelta(0.8, float64(result), 1e-6)
s.Equal(3, count)
}
func (s *MergeHelperTestSuite) TestSumMergeFunc() {
result, count := sumMergeFunc(0.5, 0.3, 1)
s.InDelta(0.8, float64(result), 1e-6)
s.Equal(2, count)
}
func (s *MergeHelperTestSuite) TestAvgMergeFunc() {
// avgMergeFunc accumulates sum; average is computed later
result, count := avgMergeFunc(0.5, 0.3, 1)
s.InDelta(0.8, float64(result), 1e-6) // accumulated sum
s.Equal(2, count)
}
// =============================================================================
// releaseChunks Tests
// =============================================================================
func (s *MergeHelperTestSuite) TestReleaseChunksWithArraysAndCollectors() {
op := NewMergeOp(MergeStrategyRRF)
// Build some arrays
idB := array.NewInt64Builder(s.pool)
idB.Append(1)
idArr := idB.NewArray()
idB.Release()
scoreB := array.NewFloat32Builder(s.pool)
scoreB.Append(0.5)
scoreArr := scoreB.NewArray()
scoreB.Release()
// Build a collector with a chunk
collector := NewChunkCollector([]string{"field_a"}, 1)
fieldB := array.NewStringBuilder(s.pool)
fieldB.Append("hello")
fieldArr := fieldB.NewArray()
fieldB.Release()
collector.Set("field_a", 0, fieldArr)
collectors := map[string]*ChunkCollector{"field_a": collector}
// releaseChunks should release everything without panic
op.releaseChunks([]arrow.Array{idArr}, []arrow.Array{scoreArr}, collectors)
}
func (s *MergeHelperTestSuite) TestReleaseChunksWithNils() {
op := NewMergeOp(MergeStrategyRRF)
// Should handle nil entries gracefully
op.releaseChunks([]arrow.Array{nil, nil}, []arrow.Array{nil}, nil)
}
func (s *MergeHelperTestSuite) TestReleaseChunksPartialArrays() {
op := NewMergeOp(MergeStrategyRRF)
idB := array.NewInt64Builder(s.pool)
idB.Append(1)
idArr := idB.NewArray()
idB.Release()
// Mixed nil and non-nil
op.releaseChunks([]arrow.Array{idArr, nil}, nil, map[string]*ChunkCollector{})
}
// =============================================================================
// Merge with typed field columns (exercises buildArrayFromLocations / getTypedValue)
// =============================================================================
func (s *MergeHelperTestSuite) TestMergeStrategyRRFWithBoolField() {
df1 := s.createDFWithBoolField([]int64{1, 2}, []float32{0.9, 0.8}, "flag", []bool{true, false})
df2 := s.createDFWithBoolField([]int64{3, 1}, []float32{0.7, 0.6}, "flag", []bool{true, true})
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategyRRF)
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
s.True(result.HasColumn("flag"))
s.Equal(int64(3), result.NumRows())
}
func (s *MergeHelperTestSuite) TestMergeStrategyRRFWithInt8Field() {
df1 := s.createDFWithInt8Field([]int64{1, 2}, []float32{0.9, 0.8}, "age", []int8{10, 20})
df2 := s.createDFWithInt8Field([]int64{3, 1}, []float32{0.7, 0.6}, "age", []int8{30, 10})
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategyRRF)
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
s.True(result.HasColumn("age"))
s.Equal(int64(3), result.NumRows())
}
func (s *MergeHelperTestSuite) TestMergeStrategyRRFWithInt16Field() {
df1 := s.createDFWithInt16Field([]int64{1, 2}, []float32{0.9, 0.8}, "val", []int16{100, 200})
df2 := s.createDFWithInt16Field([]int64{3}, []float32{0.7}, "val", []int16{300})
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategyRRF)
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
s.True(result.HasColumn("val"))
s.Equal(int64(3), result.NumRows())
}
func (s *MergeHelperTestSuite) TestMergeStrategyRRFWithInt32Field() {
df1 := s.createDFWithInt32Field([]int64{1, 2}, []float32{0.9, 0.8}, "count", []int32{1000, 2000})
df2 := s.createDFWithInt32Field([]int64{3, 1}, []float32{0.7, 0.6}, "count", []int32{3000, 1000})
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategyRRF)
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
s.True(result.HasColumn("count"))
s.Equal(int64(3), result.NumRows())
}
func (s *MergeHelperTestSuite) TestMergeStrategyRRFWithFloat32Field() {
df1 := s.createDFWithFloat32Field([]int64{1, 2}, []float32{0.9, 0.8}, "weight", []float32{1.1, 2.2})
df2 := s.createDFWithFloat32Field([]int64{3, 1}, []float32{0.7, 0.6}, "weight", []float32{3.3, 1.1})
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategyRRF)
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
s.True(result.HasColumn("weight"))
s.Equal(int64(3), result.NumRows())
}
func (s *MergeHelperTestSuite) TestMergeStrategyRRFWithFloat64Field() {
df1 := s.createDFWithFloat64Field([]int64{1, 2}, []float32{0.9, 0.8}, "distance", []float64{1.11, 2.22})
df2 := s.createDFWithFloat64Field([]int64{3, 1}, []float32{0.7, 0.6}, "distance", []float64{3.33, 1.11})
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategyRRF)
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
s.True(result.HasColumn("distance"))
s.Equal(int64(3), result.NumRows())
}
// =============================================================================
// buildTypedArrayFromLocations: missing column path (AppendNull)
// =============================================================================
func (s *MergeHelperTestSuite) TestMergeWithMissingFieldColumn() {
// df1 has "extra" column, df2 does NOT -> buildTypedArrayFromLocations appends null
df1 := s.createDFWithField([]int64{1, 2}, []float32{0.9, 0.8}, "extra", []string{"a", "b"}, []int64{2})
df2 := s.createDF([]int64{3}, []float32{0.7}, []int64{1})
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategyRRF)
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
s.True(result.HasColumn("extra"))
s.Equal(int64(3), result.NumRows())
// ID=3 came from df2 which has no "extra" column, so it should be null
extraCol := result.Column("extra")
s.NotNil(extraCol)
chunk := extraCol.Chunk(0)
// Find the row index for ID=3 (should be null)
idCol := result.Column(types.IDFieldName)
idChunk := idCol.Chunk(0).(*array.Int64)
for i := 0; i < idChunk.Len(); i++ {
if idChunk.Value(i) == 3 {
s.True(chunk.IsNull(i), "ID=3 should have null 'extra' field")
}
}
}
// =============================================================================
// buildFieldArray: empty locs path
// =============================================================================
func (s *MergeHelperTestSuite) TestMergeWithEmptyChunk() {
// Create a DataFrame with 2 chunks where one query returns empty results
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{2, 0})
idB := array.NewInt64Builder(s.pool)
idB.AppendValues([]int64{1, 2}, nil)
idArr := idB.NewArray()
idB.Release()
scoreB := array.NewFloat32Builder(s.pool)
scoreB.AppendValues([]float32{0.9, 0.8}, nil)
scoreArr := scoreB.NewArray()
scoreB.Release()
// Empty chunk for second query
idB2 := array.NewInt64Builder(s.pool)
idArr2 := idB2.NewArray()
idB2.Release()
scoreB2 := array.NewFloat32Builder(s.pool)
scoreArr2 := scoreB2.NewArray()
scoreB2.Release()
fieldB := array.NewStringBuilder(s.pool)
fieldB.AppendValues([]string{"a", "b"}, nil)
fieldArr := fieldB.NewArray()
fieldB.Release()
fieldB2 := array.NewStringBuilder(s.pool)
fieldArr2 := fieldB2.NewArray()
fieldB2.Release()
err := builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idArr, idArr2})
s.Require().NoError(err)
err = builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreArr, scoreArr2})
s.Require().NoError(err)
err = builder.AddColumnFromChunks("name", []arrow.Array{fieldArr, fieldArr2})
s.Require().NoError(err)
df := builder.Build()
defer df.Release()
// Merge single input, no normalization -> pass through
op := NewMergeOp(MergeStrategyRRF, WithNormalize(false))
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.Execute(ctx, df)
s.Require().NoError(err)
defer result.Release()
s.Equal(2, result.NumChunks())
}
// =============================================================================
// Single Input Tests
// =============================================================================
func (s *MergeHelperTestSuite) TestSingleInputRRFProducesRankBasedScores() {
df := s.createDF([]int64{1, 2, 3}, []float32{0.5, 0.8, 0.3}, []int64{3})
defer df.Release()
op := NewMergeOp(MergeStrategyRRF, WithNormalize(false))
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.Execute(ctx, df)
s.Require().NoError(err)
defer result.Release()
// Single input RRF should compute rank-based scores: 1/(k+rank)
s.Equal(int64(3), result.NumRows())
scores := result.Column(types.ScoreFieldName).Chunk(0).(*array.Float32)
s.InDelta(1.0/(60+1), float64(scores.Value(0)), 1e-6)
s.InDelta(1.0/(60+2), float64(scores.Value(1)), 1e-6)
s.InDelta(1.0/(60+3), float64(scores.Value(2)), 1e-6)
}
func (s *MergeHelperTestSuite) TestSingleInputWeightedAppliesWeight() {
df := s.createDF([]int64{1, 2}, []float32{0.5, -0.5}, []int64{2})
defer df.Release()
op := NewMergeOp(MergeStrategyWeighted,
WithWeights([]float64{0.7}),
WithNormalize(true),
WithMetricTypes([]string{"COSINE"}))
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.Execute(ctx, df)
s.Require().NoError(err)
defer result.Release()
// COSINE normalize: (1+score)*0.5, then multiply by weight 0.7
// ID 1: (1+0.5)*0.5 * 0.7 = 0.525
// ID 2: (1-0.5)*0.5 * 0.7 = 0.175
s.Equal(int64(2), result.NumRows())
scores := result.Column(types.ScoreFieldName).Chunk(0).(*array.Float32)
s.InDelta(0.525, float64(scores.Value(0)), 1e-6)
s.InDelta(0.175, float64(scores.Value(1)), 1e-6)
}
func (s *MergeHelperTestSuite) TestSingleInputRRFIgnoresOriginalScores() {
df := s.createDF([]int64{1}, []float32{0.5}, []int64{1})
defer df.Release()
// RRF computes rank-based scores regardless of normalize or metricTypes
op := NewMergeOp(MergeStrategyRRF, WithNormalize(true))
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.Execute(ctx, df)
s.Require().NoError(err)
defer result.Release()
scores := result.Column(types.ScoreFieldName).Chunk(0).(*array.Float32)
s.InDelta(1.0/(60+1), float64(scores.Value(0)), 1e-6) // RRF score, not original
}
func (s *MergeHelperTestSuite) TestSingleInputWeightedScoreNotFloat32() {
// Create DF with int64 score column (not Float32)
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{1})
idB := array.NewInt64Builder(s.pool)
idB.Append(1)
idArr := idB.NewArray()
idB.Release()
scoreB := array.NewInt64Builder(s.pool)
scoreB.Append(100)
scoreArr := scoreB.NewArray()
scoreB.Release()
err := builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idArr})
s.Require().NoError(err)
err = builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreArr})
s.Require().NoError(err)
df := builder.Build()
defer df.Release()
// Weighted strategy reads scores, so non-Float32 should fail
op := NewMergeOp(MergeStrategyWeighted,
WithWeights([]float64{1.0}),
WithNormalize(true),
WithMetricTypes([]string{"COSINE"}))
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
_, err = op.Execute(ctx, df)
s.Error(err)
s.Contains(err.Error(), "not Float32")
}
// =============================================================================
// mergeNumCombine Tests (max/sum/avg with actual DataFrames)
// =============================================================================
func (s *MergeHelperTestSuite) TestMergeMaxStrategy() {
df1 := s.createDF([]int64{1, 2, 3}, []float32{0.5, 0.8, 0.3}, []int64{3})
df2 := s.createDF([]int64{1, 2, 4}, []float32{0.7, 0.6, 0.9}, []int64{3})
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategyMax, WithNormalize(false))
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
s.Equal(int64(4), result.NumRows())
// Verify max scores: ID=1 max(0.5,0.7)=0.7, ID=2 max(0.8,0.6)=0.8
idChunk := result.Column(types.IDFieldName).Chunk(0).(*array.Int64)
scoreChunk := result.Column(types.ScoreFieldName).Chunk(0).(*array.Float32)
idScoreMap := make(map[int64]float32)
for i := 0; i < idChunk.Len(); i++ {
idScoreMap[idChunk.Value(i)] = scoreChunk.Value(i)
}
s.InDelta(0.7, float64(idScoreMap[1]), 1e-6)
s.InDelta(0.8, float64(idScoreMap[2]), 1e-6)
s.InDelta(0.3, float64(idScoreMap[3]), 1e-6)
s.InDelta(0.9, float64(idScoreMap[4]), 1e-6)
}
func (s *MergeHelperTestSuite) TestMergeSumStrategy() {
df1 := s.createDF([]int64{1, 2}, []float32{0.5, 0.8}, []int64{2})
df2 := s.createDF([]int64{1, 3}, []float32{0.3, 0.9}, []int64{2})
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategySum, WithNormalize(false))
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
s.Equal(int64(3), result.NumRows())
idChunk := result.Column(types.IDFieldName).Chunk(0).(*array.Int64)
scoreChunk := result.Column(types.ScoreFieldName).Chunk(0).(*array.Float32)
idScoreMap := make(map[int64]float32)
for i := 0; i < idChunk.Len(); i++ {
idScoreMap[idChunk.Value(i)] = scoreChunk.Value(i)
}
s.InDelta(0.8, float64(idScoreMap[1]), 1e-6) // 0.5+0.3
s.InDelta(0.8, float64(idScoreMap[2]), 1e-6)
s.InDelta(0.9, float64(idScoreMap[3]), 1e-6)
}
func (s *MergeHelperTestSuite) TestMergeAvgStrategy() {
df1 := s.createDF([]int64{1, 2}, []float32{0.4, 0.8}, []int64{2})
df2 := s.createDF([]int64{1, 3}, []float32{0.6, 0.9}, []int64{2})
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategyAvg, WithNormalize(false))
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
s.Equal(int64(3), result.NumRows())
idChunk := result.Column(types.IDFieldName).Chunk(0).(*array.Int64)
scoreChunk := result.Column(types.ScoreFieldName).Chunk(0).(*array.Float32)
idScoreMap := make(map[int64]float32)
for i := 0; i < idChunk.Len(); i++ {
idScoreMap[idChunk.Value(i)] = scoreChunk.Value(i)
}
s.InDelta(0.5, float64(idScoreMap[1]), 1e-6) // (0.4+0.6)/2
s.InDelta(0.8, float64(idScoreMap[2]), 1e-6) // only appears once
s.InDelta(0.9, float64(idScoreMap[3]), 1e-6)
}
func (s *MergeHelperTestSuite) TestMergeNumCombineMissingIDColumn() {
// Create DF without ID column
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{1})
scoreB := array.NewFloat32Builder(s.pool)
scoreB.Append(0.5)
scoreArr := scoreB.NewArray()
scoreB.Release()
err := builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreArr})
s.Require().NoError(err)
df1 := builder.Build()
defer df1.Release()
df2 := s.createDF([]int64{1}, []float32{0.5}, []int64{1})
defer df2.Release()
op := NewMergeOp(MergeStrategyMax, WithNormalize(false))
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
_, err = op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Error(err)
s.Contains(err.Error(), "missing ID or score column")
}
func (s *MergeHelperTestSuite) TestMergeNumCombineMissingScoreColumn() {
// Create DF without score column
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{1})
idB := array.NewInt64Builder(s.pool)
idB.Append(1)
idArr := idB.NewArray()
idB.Release()
err := builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idArr})
s.Require().NoError(err)
df1 := builder.Build()
defer df1.Release()
df2 := s.createDF([]int64{1}, []float32{0.5}, []int64{1})
defer df2.Release()
op := NewMergeOp(MergeStrategySum, WithNormalize(false))
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
_, err = op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Error(err)
s.Contains(err.Error(), "missing ID or score column")
}
func (s *MergeHelperTestSuite) TestMergeNumCombineScoreNotFloat32() {
// Create DF with int64 score column
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{1})
idB := array.NewInt64Builder(s.pool)
idB.Append(1)
idArr := idB.NewArray()
idB.Release()
scoreB := array.NewInt64Builder(s.pool)
scoreB.Append(100)
scoreArr := scoreB.NewArray()
scoreB.Release()
err := builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idArr})
s.Require().NoError(err)
err = builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreArr})
s.Require().NoError(err)
df1 := builder.Build()
defer df1.Release()
df2 := s.createDF([]int64{1}, []float32{0.5}, []int64{1})
defer df2.Release()
op := NewMergeOp(MergeStrategyMax, WithNormalize(false))
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
_, err = op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Error(err)
s.Contains(err.Error(), "not Float32")
}
func (s *MergeHelperTestSuite) TestMergeNumCombineWithNormalization() {
df1 := s.createDF([]int64{1, 2}, []float32{0.5, 0.8}, []int64{2})
df2 := s.createDF([]int64{1, 3}, []float32{0.3, 0.9}, []int64{2})
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategyMax, WithNormalize(true), WithMetricTypes([]string{"COSINE", "L2"}))
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
s.Equal(int64(3), result.NumRows())
}
func (s *MergeHelperTestSuite) TestMergeNumCombineWithMixedMetricsNoNormalize() {
df1 := s.createDF([]int64{1, 2}, []float32{0.5, 0.8}, []int64{2})
df2 := s.createDF([]int64{1, 3}, []float32{0.3, 0.9}, []int64{2})
defer df1.Release()
defer df2.Release()
// Mixed metrics (COSINE + L2) without normalize -> applies direction conversion
op := NewMergeOp(MergeStrategySum, WithNormalize(false), WithMetricTypes([]string{"COSINE", "L2"}))
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
s.Equal(int64(3), result.NumRows())
}
// =============================================================================
// ExecuteMulti validation Tests
// =============================================================================
func (s *MergeHelperTestSuite) TestMergeNoInputs() {
op := NewMergeOp(MergeStrategyRRF)
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
_, err := op.ExecuteMulti(ctx, []*DataFrame{})
s.Error(err)
s.Contains(err.Error(), "no inputs provided")
}
func (s *MergeHelperTestSuite) TestMergeMismatchedChunks() {
df1 := s.createDF([]int64{1}, []float32{0.5}, []int64{1})
// df2 has 2 chunks
df2 := s.createDF([]int64{1, 2}, []float32{0.5, 0.8}, []int64{1, 1})
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategyRRF)
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
_, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Error(err)
s.Contains(err.Error(), "chunks")
}
func (s *MergeHelperTestSuite) TestMergeMetricTypesCountMismatch() {
df1 := s.createDF([]int64{1}, []float32{0.5}, []int64{1})
df2 := s.createDF([]int64{2}, []float32{0.8}, []int64{1})
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategyRRF, WithMetricTypes([]string{"COSINE"})) // 1 metric, 2 inputs
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
_, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Error(err)
s.Contains(err.Error(), "scoreNormFuncs count")
}
func (s *MergeHelperTestSuite) TestMergeWeightedCountMismatch() {
df1 := s.createDF([]int64{1}, []float32{0.5}, []int64{1})
df2 := s.createDF([]int64{2}, []float32{0.8}, []int64{1})
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategyWeighted, WithWeights([]float64{0.5})) // 1 weight, 2 inputs
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
_, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Error(err)
s.Contains(err.Error(), "weights count")
}
func (s *MergeHelperTestSuite) TestMergeUnsupportedStrategy() {
df1 := s.createDF([]int64{1}, []float32{0.5}, []int64{1})
df2 := s.createDF([]int64{2}, []float32{0.8}, []int64{1})
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategy("unknown"))
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
_, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Error(err)
s.Contains(err.Error(), "unsupported strategy")
}
// =============================================================================
// MergeOp String/Name Tests
// =============================================================================
func (s *MergeHelperTestSuite) TestMergeOpString() {
op := NewMergeOp(MergeStrategyRRF)
s.Equal("Merge(rrf)", op.String())
op2 := NewMergeOp(MergeStrategyWeighted)
s.Equal("Merge(weighted)", op2.String())
}
func (s *MergeHelperTestSuite) TestMergeOpName() {
op := NewMergeOp(MergeStrategyRRF)
s.Equal("Merge", op.Name())
}
// =============================================================================
// MergeOp with field columns across strategies
// =============================================================================
func (s *MergeHelperTestSuite) TestMergeWeightedWithFieldColumns() {
df1 := s.createDFWithField([]int64{1, 2}, []float32{0.9, 0.8}, "name", []string{"alice", "bob"}, []int64{2})
df2 := s.createDFWithField([]int64{2, 3}, []float32{0.7, 0.6}, "name", []string{"bob", "carol"}, []int64{2})
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategyWeighted, WithWeights([]float64{0.5, 0.5}), WithNormalize(false))
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
s.True(result.HasColumn("name"))
s.Equal(int64(3), result.NumRows())
}
func (s *MergeHelperTestSuite) TestMergeSumWithFieldColumns() {
df1 := s.createDFWithField([]int64{1}, []float32{0.5}, "tag", []string{"x"}, []int64{1})
df2 := s.createDFWithField([]int64{1, 2}, []float32{0.3, 0.8}, "tag", []string{"x", "y"}, []int64{2})
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategySum, WithNormalize(false))
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
s.True(result.HasColumn("tag"))
s.Equal(int64(2), result.NumRows())
}
// =============================================================================
// buildTypedArrayFromLocations: null value path
// =============================================================================
func (s *MergeHelperTestSuite) TestMergeWithNullFieldValues() {
// Create DF with null values in the field column
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{2})
idB := array.NewInt64Builder(s.pool)
idB.AppendValues([]int64{1, 2}, nil)
idArr := idB.NewArray()
idB.Release()
scoreB := array.NewFloat32Builder(s.pool)
scoreB.AppendValues([]float32{0.9, 0.8}, nil)
scoreArr := scoreB.NewArray()
scoreB.Release()
fieldB := array.NewStringBuilder(s.pool)
fieldB.Append("hello")
fieldB.AppendNull() // null value for ID=2
fieldArr := fieldB.NewArray()
fieldB.Release()
err := builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idArr})
s.Require().NoError(err)
err = builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreArr})
s.Require().NoError(err)
err = builder.AddColumnFromChunks("text", []arrow.Array{fieldArr})
s.Require().NoError(err)
df1 := builder.Build()
defer df1.Release()
df2 := s.createDFWithField([]int64{3}, []float32{0.7}, "text", []string{"world"}, []int64{1})
defer df2.Release()
op := NewMergeOp(MergeStrategyRRF)
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
s.True(result.HasColumn("text"))
s.Equal(int64(3), result.NumRows())
// Verify that the null field value is preserved
textCol := result.Column("text")
idCol := result.Column(types.IDFieldName)
idChunk := idCol.Chunk(0).(*array.Int64)
textChunk := textCol.Chunk(0)
for i := 0; i < idChunk.Len(); i++ {
if idChunk.Value(i) == 2 {
s.True(textChunk.IsNull(i), "ID=2 should have null 'text' field")
}
}
}
// =============================================================================
// classifyMetricsOrder Tests
// =============================================================================
func (s *MergeHelperTestSuite) TestClassifyMetricsOrderAllLargerBetter() {
mixed, desc := classifyMetricsOrder([]string{"COSINE", "IP", "BM25"})
s.False(mixed)
s.True(desc)
}
func (s *MergeHelperTestSuite) TestClassifyMetricsOrderAllSmallerBetter() {
mixed, desc := classifyMetricsOrder([]string{"L2", "HAMMING"})
s.False(mixed)
s.False(desc)
}
func (s *MergeHelperTestSuite) TestClassifyMetricsOrderMixed() {
mixed, desc := classifyMetricsOrder([]string{"COSINE", "L2"})
s.True(mixed)
s.True(desc) // mixed -> descending
}
// =============================================================================
// Multi-chunk merge (exercises chunkIdx > 0 paths)
// =============================================================================
func (s *MergeHelperTestSuite) TestMergeStrategyRRFMultiChunk() {
// 2 chunks per DF (2 queries)
df1 := s.createDF([]int64{1, 2, 3, 4}, []float32{0.9, 0.8, 0.7, 0.6}, []int64{2, 2})
df2 := s.createDF([]int64{2, 5, 4, 6}, []float32{0.8, 0.7, 0.6, 0.5}, []int64{2, 2})
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategyRRF)
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
s.Equal(2, result.NumChunks())
}
func (s *MergeHelperTestSuite) TestMergeMaxMultiChunkWithFields() {
df1 := s.createDFWithField(
[]int64{1, 2, 3, 4},
[]float32{0.9, 0.8, 0.7, 0.6},
"name", []string{"a", "b", "c", "d"},
[]int64{2, 2},
)
df2 := s.createDFWithField(
[]int64{2, 5, 4, 6},
[]float32{0.85, 0.7, 0.65, 0.5},
"name", []string{"b", "e", "d", "f"},
[]int64{2, 2},
)
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategyMax, WithNormalize(false))
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
s.Equal(2, result.NumChunks())
s.True(result.HasColumn("name"))
}
// =============================================================================
// MergeOp.Execute (single input wrapper) Tests
// =============================================================================
func (s *MergeHelperTestSuite) TestMergeOpExecuteSingleInput() {
df := s.createDF([]int64{1, 2}, []float32{0.5, 0.8}, []int64{2})
defer df.Release()
op := NewMergeOp(MergeStrategyRRF, WithNormalize(false))
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.Execute(ctx, df)
s.Require().NoError(err)
defer result.Release()
s.Equal(int64(2), result.NumRows())
}
// =============================================================================
// Weighted merge with normalization
// =============================================================================
func (s *MergeHelperTestSuite) TestMergeWeightedWithNormalization() {
df1 := s.createDF([]int64{1, 2}, []float32{0.5, 0.8}, []int64{2})
df2 := s.createDF([]int64{1, 3}, []float32{0.3, 0.9}, []int64{2})
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategyWeighted,
WithWeights([]float64{0.6, 0.4}),
WithNormalize(true),
WithMetricTypes([]string{"COSINE", "L2"}),
)
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
s.Equal(int64(3), result.NumRows())
}
func (s *MergeHelperTestSuite) TestMergeWeightedMissingIDOrScore() {
// Create DF without ID column
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{1})
scoreB := array.NewFloat32Builder(s.pool)
scoreB.Append(0.5)
scoreArr := scoreB.NewArray()
scoreB.Release()
err := builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreArr})
s.Require().NoError(err)
df1 := builder.Build()
defer df1.Release()
df2 := s.createDF([]int64{1}, []float32{0.5}, []int64{1})
defer df2.Release()
op := NewMergeOp(MergeStrategyWeighted, WithWeights([]float64{0.5, 0.5}), WithNormalize(false))
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
_, err = op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Error(err)
s.Contains(err.Error(), "missing ID or score column")
}
func (s *MergeHelperTestSuite) TestMergeWeightedScoreNotFloat32() {
// Create DF with int64 score column
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{1})
idB := array.NewInt64Builder(s.pool)
idB.Append(1)
idArr := idB.NewArray()
idB.Release()
scoreB := array.NewInt64Builder(s.pool)
scoreB.Append(100)
scoreArr := scoreB.NewArray()
scoreB.Release()
err := builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idArr})
s.Require().NoError(err)
err = builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreArr})
s.Require().NoError(err)
df1 := builder.Build()
defer df1.Release()
df2 := s.createDF([]int64{1}, []float32{0.5}, []int64{1})
defer df2.Release()
op := NewMergeOp(MergeStrategyWeighted, WithWeights([]float64{0.5, 0.5}), WithNormalize(false))
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
_, err = op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Error(err)
s.Contains(err.Error(), "not Float32")
}
// =============================================================================
// RRF merge error paths
// =============================================================================
func (s *MergeHelperTestSuite) TestMergeStrategyRRFMissingIDColumn() {
// Create DF without ID column
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{1})
scoreB := array.NewFloat32Builder(s.pool)
scoreB.Append(0.5)
scoreArr := scoreB.NewArray()
scoreB.Release()
err := builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreArr})
s.Require().NoError(err)
df1 := builder.Build()
defer df1.Release()
df2 := s.createDF([]int64{1}, []float32{0.5}, []int64{1})
defer df2.Release()
op := NewMergeOp(MergeStrategyRRF)
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
_, err = op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Error(err)
s.Contains(err.Error(), "missing")
}
// =============================================================================
// Weighted merge with mixed metrics and direction conversion (no normalize)
// =============================================================================
func (s *MergeHelperTestSuite) TestMergeWeightedMixedMetricsNoNormalize() {
df1 := s.createDF([]int64{1, 2}, []float32{0.5, 0.8}, []int64{2})
df2 := s.createDF([]int64{1, 3}, []float32{0.3, 0.9}, []int64{2})
defer df1.Release()
defer df2.Release()
op := NewMergeOp(MergeStrategyWeighted,
WithWeights([]float64{0.6, 0.4}),
WithNormalize(false),
WithMetricTypes([]string{"COSINE", "L2"}), // mixed
)
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
s.Equal(int64(3), result.NumRows())
}
// TestMemoryLeak_MergePartialChunkError verifies that when mergeWithScoreCollector
// fails mid-way (after some chunks have been allocated), the deferred cleanup releases
// all intermediate Arrow arrays. The CheckedAllocator in TearDownTest asserts zero
// outstanding bytes, so any leak will fail the test.
func (s *MergeHelperTestSuite) TestMemoryLeak_MergePartialChunkError() {
// Create 2 valid 2-chunk inputs so chunk 0 succeeds and allocates id/score arrays.
df1 := s.createDF(
[]int64{1, 2, 3, 4},
[]float32{0.9, 0.8, 0.7, 0.6},
[]int64{2, 2},
)
defer df1.Release()
df2 := s.createDF(
[]int64{2, 3, 5, 6},
[]float32{0.85, 0.75, 0.65, 0.55},
[]int64{2, 2},
)
defer df2.Release()
op := NewMergeOp(MergeStrategyRRF)
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
// Inject a collectFn that succeeds on chunk 0 (allocating id/score arrays)
// but fails on chunk 1, exercising the deferred cleanup of partial allocations.
callCount := 0
failOnSecondChunk := func(inputs []*DataFrame, chunkIdx int) (map[any]float32, map[any]idLocation, error) {
callCount++
if callCount > 1 {
return nil, nil, fmt.Errorf("injected error on chunk %d", chunkIdx)
}
return op.collectRRFScores(inputs, chunkIdx)
}
// Run 10 iterations to amplify any leak into a visible CheckedAllocator failure.
for i := 0; i < 10; i++ {
callCount = 0
_, err := op.mergeWithScoreCollector(ctx, []*DataFrame{df1, df2}, failOnSecondChunk)
s.Error(err)
s.Contains(err.Error(), "injected error")
}
// TearDownTest asserts s.pool.AssertSize(s.T(), 0) — any leaked arrays will fail.
}
// TestMemoryLeak_MergeFieldCollectorError verifies cleanup when an error occurs
// during the field-collector assembly phase (after all chunks have been built
// but during AddColumnFromChunks for field columns).
func (s *MergeHelperTestSuite) TestMemoryLeak_MergeFieldCollectorPartialSuccess() {
// Create 2 inputs with field columns — chunk 0 succeeds fully, building
// id/score arrays and field collector data for both chunks.
df1 := s.createDFWithField(
[]int64{1, 2, 3, 4},
[]float32{0.9, 0.8, 0.7, 0.6},
"text",
[]string{"a", "b", "c", "d"},
[]int64{2, 2},
)
defer df1.Release()
df2 := s.createDFWithField(
[]int64{2, 3, 5, 6},
[]float32{0.85, 0.75, 0.65, 0.55},
"text",
[]string{"e", "f", "g", "h"},
[]int64{2, 2},
)
defer df2.Release()
op := NewMergeOp(MergeStrategyRRF)
ctx := types.NewFuncContextFull(context.TODO(), s.pool, "rerank")
// First verify the success path works (sanity check).
result, err := op.mergeWithScoreCollector(ctx, []*DataFrame{df1, df2}, op.collectRRFScores)
s.Require().NoError(err)
result.Release()
// Now inject an error after building all chunks but during field assembly.
// We do this by making collectFn succeed for all chunks, then replacing
// the collected field data with a broken collector that causes an error
// in buildFieldArray. Since we can't easily break buildFieldArray,
// we instead verify the success path doesn't leak via CheckedAllocator.
for i := 0; i < 10; i++ {
res, err := op.mergeWithScoreCollector(ctx, []*DataFrame{df1, df2}, op.collectRRFScores)
s.Require().NoError(err)
res.Release()
}
// TearDownTest asserts zero outstanding allocations.
}