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

2615 lines
78 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"
"math"
"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-proto/go-api/v3/schemapb"
"github.com/milvus-io/milvus/internal/util/function/chain/types"
)
// =============================================================================
// Test Suite
// =============================================================================
type ChainTestSuite struct {
suite.Suite
pool *memory.CheckedAllocator
}
func (s *ChainTestSuite) SetupTest() {
s.pool = memory.NewCheckedAllocator(memory.NewGoAllocator())
}
func (s *ChainTestSuite) TearDownTest() {
s.pool.AssertSize(s.T(), 0)
}
func TestChainTestSuite(t *testing.T) {
suite.Run(t, new(ChainTestSuite))
}
// =============================================================================
// Helper Functions
// =============================================================================
func (s *ChainTestSuite) createTestDataFrame() *DataFrame {
resultData := &schemapb.SearchResultData{
NumQueries: 2,
TopK: 5,
Topks: []int64{5, 4},
Scores: []float32{0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1},
Ids: &schemapb.IDs{
IdField: &schemapb.IDs_IntId{
IntId: &schemapb.LongArray{
Data: []int64{1, 2, 3, 4, 5, 6, 7, 8, 9},
},
},
},
FieldsData: []*schemapb.FieldData{
{
Type: schemapb.DataType_Int64,
FieldName: "age",
FieldId: 100,
Field: &schemapb.FieldData_Scalars{
Scalars: &schemapb.ScalarField{
Data: &schemapb.ScalarField_LongData{
LongData: &schemapb.LongArray{
Data: []int64{25, 30, 35, 40, 45, 50, 55, 60, 65},
},
},
},
},
},
{
Type: schemapb.DataType_VarChar,
FieldName: "name",
FieldId: 101,
Field: &schemapb.FieldData_Scalars{
Scalars: &schemapb.ScalarField{
Data: &schemapb.ScalarField_StringData{
StringData: &schemapb.StringArray{
Data: []string{"alice", "bob", "charlie", "david", "eve", "frank", "grace", "henry", "ivy"},
},
},
},
},
},
},
}
df, err := FromSearchResultData(resultData, s.pool, []string{"age", "name"})
s.Require().NoError(err)
return df
}
// =============================================================================
// FuncChain Basic Tests
// =============================================================================
func (s *ChainTestSuite) TestNewFuncChain_NilAllocator() {
fc := NewFuncChainWithAllocator(nil)
s.NotNil(fc)
s.Empty(fc.operators)
s.NotNil(fc.alloc)
}
func (s *ChainTestSuite) TestNewFuncChainWithAllocator() {
fc := NewFuncChainWithAllocator(s.pool)
s.NotNil(fc)
s.Equal(s.pool, fc.alloc)
}
func (s *ChainTestSuite) TestFuncChainSetName() {
fc := NewFuncChainWithAllocator(nil).SetName("test-chain")
s.Equal("test-chain", fc.name)
}
func (s *ChainTestSuite) TestFuncChainString() {
fc := NewFuncChainWithAllocator(nil).SetName("test-chain")
str := fc.String()
s.Contains(str, "FuncChain: test-chain")
}
// =============================================================================
// SelectOp Tests
// =============================================================================
func (s *ChainTestSuite) TestSelectOp() {
df := s.createTestDataFrame()
defer df.Release()
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Select(types.IDFieldName, "age").
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Verify selected non-system columns exist and unselected non-system columns are removed.
// System columns are preserved by SelectOp because downstream reduce paths may need them.
s.True(result.HasColumn(types.IDFieldName))
s.True(result.HasColumn(types.ScoreFieldName))
s.True(result.HasColumn("age"))
s.False(result.HasColumn("name"))
// Verify data integrity
s.Equal(df.NumRows(), result.NumRows())
s.Equal(df.NumChunks(), result.NumChunks())
}
func (s *ChainTestSuite) TestSelectOp_NonExistentColumn() {
df := s.createTestDataFrame()
defer df.Release()
_, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Select("nonexistent").
Execute(df)
s.Error(err)
}
// =============================================================================
// FilterOp Tests
// =============================================================================
// MockFilterFunction creates a boolean column for filtering
type MockFilterFunction struct {
threshold float32
}
func (f *MockFilterFunction) Name() string { return "MockFilter" }
func (f *MockFilterFunction) OutputDataTypes() []arrow.DataType {
return []arrow.DataType{arrow.FixedWidthTypes.Boolean}
}
func (f *MockFilterFunction) IsRunnable(stage string) bool { return true }
func (f *MockFilterFunction) Execute(ctx *types.FuncContext, inputs []*arrow.Chunked) ([]*arrow.Chunked, error) {
col := inputs[0]
chunks := make([]arrow.Array, len(col.Chunks()))
for i, chunk := range col.Chunks() {
floatChunk := chunk.(*array.Float32)
builder := array.NewBooleanBuilder(ctx.Pool())
for j := range floatChunk.Len() {
if floatChunk.IsNull(j) {
builder.AppendNull()
} else {
builder.Append(floatChunk.Value(j) >= f.threshold)
}
}
chunks[i] = builder.NewArray()
builder.Release()
}
result := arrow.NewChunked(arrow.FixedWidthTypes.Boolean, chunks)
// Release individual arrays after creating chunked
for _, chunk := range chunks {
chunk.Release()
}
return []*arrow.Chunked{result}, nil
}
func (s *ChainTestSuite) TestFilterOp() {
df := s.createTestDataFrame()
defer df.Release()
// Filter with FunctionExpr that returns boolean
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Filter(&MockFilterFunction{threshold: 0.5}, []string{types.ScoreFieldName}).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Verify filtered results - scores >= 0.5 should remain
// Chunk 0: 0.9, 0.8, 0.7, 0.6, 0.5 (all 5 pass)
// Chunk 1: 0.4, 0.3, 0.2, 0.1 (none pass)
s.Equal(int64(5), result.NumRows())
s.Equal([]int64{5, 0}, result.ChunkSizes())
}
func (s *ChainTestSuite) TestFilterOp_NonExistentColumn() {
df := s.createTestDataFrame()
defer df.Release()
_, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Filter(&MockFilterFunction{threshold: 0.5}, []string{"nonexistent"}).
Execute(df)
s.Error(err)
}
// =============================================================================
// SortOp Tests
// =============================================================================
func (s *ChainTestSuite) TestSortOp_Ascending() {
df := s.createTestDataFrame()
defer df.Release()
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Sort("age", false, types.IDFieldName). // ascending
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Verify sorted - each chunk should be sorted independently
ageCol := result.Column("age")
// Check chunk 0 is sorted ascending
chunk0 := ageCol.Chunk(0).(*array.Int64)
for i := 1; i < chunk0.Len(); i++ {
s.LessOrEqual(chunk0.Value(i-1), chunk0.Value(i))
}
// Check chunk 1 is sorted ascending
if len(ageCol.Chunks()) > 1 {
chunk1 := ageCol.Chunk(1).(*array.Int64)
for i := 1; i < chunk1.Len(); i++ {
s.LessOrEqual(chunk1.Value(i-1), chunk1.Value(i))
}
}
}
func (s *ChainTestSuite) TestSortOp_Descending() {
df := s.createTestDataFrame()
defer df.Release()
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Sort("age", true, types.IDFieldName). // descending
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Verify sorted descending
ageCol := result.Column("age")
// Check chunk 0 is sorted descending
chunk0 := ageCol.Chunk(0).(*array.Int64)
for i := 1; i < chunk0.Len(); i++ {
s.GreaterOrEqual(chunk0.Value(i-1), chunk0.Value(i))
}
}
func (s *ChainTestSuite) TestSortOp_NonExistentColumn() {
df := s.createTestDataFrame()
defer df.Release()
_, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Sort("nonexistent", false, types.IDFieldName).
Execute(df)
s.Error(err)
}
// =============================================================================
// LimitOp Tests
// =============================================================================
func (s *ChainTestSuite) TestLimitOp() {
df := s.createTestDataFrame()
defer df.Release()
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Limit(3).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Each chunk should be limited to 3 rows
// Chunk 0: 5 -> 3
// Chunk 1: 4 -> 3
s.Equal([]int64{3, 3}, result.ChunkSizes())
s.Equal(int64(6), result.NumRows())
}
func (s *ChainTestSuite) TestLimitOp_WithOffset() {
df := s.createTestDataFrame()
defer df.Release()
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
LimitWithOffset(2, 1).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Offset 1, Limit 2 for each chunk
// Chunk 0: 5 rows, skip 1, take 2 -> 2 rows
// Chunk 1: 4 rows, skip 1, take 2 -> 2 rows
s.Equal([]int64{2, 2}, result.ChunkSizes())
s.Equal(int64(4), result.NumRows())
}
func (s *ChainTestSuite) TestLimitOp_LargerThanChunk() {
df := s.createTestDataFrame()
defer df.Release()
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Limit(100).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Limit larger than chunk size should return all rows
s.Equal(df.ChunkSizes(), result.ChunkSizes())
s.Equal(df.NumRows(), result.NumRows())
}
func (s *ChainTestSuite) TestLimitOp_OffsetBeyondChunk() {
df := s.createTestDataFrame()
defer df.Release()
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
LimitWithOffset(10, 100).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Offset beyond chunk size should return empty chunks
s.Equal([]int64{0, 0}, result.ChunkSizes())
s.Equal(int64(0), result.NumRows())
}
// =============================================================================
// MapOp Tests
// =============================================================================
// MockAddColumnFunction adds a constant column
// Note: This function needs to know the chunk sizes, so it uses a special approach
// by taking $id column as input to determine the chunk structure
type MockAddColumnFunction struct {
value int64
}
func (f *MockAddColumnFunction) Name() string { return "MockAddColumn" }
func (f *MockAddColumnFunction) OutputDataTypes() []arrow.DataType {
return []arrow.DataType{arrow.PrimitiveTypes.Int64}
}
func (f *MockAddColumnFunction) IsRunnable(stage string) bool { return true }
func (f *MockAddColumnFunction) Execute(ctx *types.FuncContext, inputs []*arrow.Chunked) ([]*arrow.Chunked, error) {
// Use input column to determine chunk sizes
idCol := inputs[0]
chunks := make([]arrow.Array, len(idCol.Chunks()))
for i, chunk := range idCol.Chunks() {
builder := array.NewInt64Builder(ctx.Pool())
for range chunk.Len() {
builder.Append(f.value)
}
chunks[i] = builder.NewArray()
builder.Release()
}
result := arrow.NewChunked(arrow.PrimitiveTypes.Int64, chunks)
// Release individual arrays after creating chunked
for _, chunk := range chunks {
chunk.Release()
}
return []*arrow.Chunked{result}, nil
}
func (s *ChainTestSuite) TestMapOp() {
df := s.createTestDataFrame()
defer df.Release()
// Column mapping is now at operator level
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Map(&MockAddColumnFunction{value: 42}, []string{types.IDFieldName}, []string{"constant"}).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Verify new column exists
s.True(result.HasColumn("constant"))
// Verify all values are 42
col := result.Column("constant")
for i := range len(col.Chunks()) {
chunk := col.Chunk(i).(*array.Int64)
for j := range chunk.Len() {
s.Equal(int64(42), chunk.Value(j))
}
}
}
func (s *ChainTestSuite) TestMapOp_NilFunction() {
df := s.createTestDataFrame()
defer df.Release()
// With new Map signature, nil function should error in NewMapOp
_, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Map(nil, []string{}, []string{}).
Execute(df)
s.Error(err)
}
// =============================================================================
// Chained Operations Tests
// =============================================================================
func (s *ChainTestSuite) TestChainedOperations() {
df := s.createTestDataFrame()
defer df.Release()
// Filter -> Select -> Sort -> Limit
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Filter(&MockFilterFunction{threshold: 0.3}, []string{types.ScoreFieldName}).
Select(types.IDFieldName, types.ScoreFieldName, "age").
Sort(types.ScoreFieldName, true, types.IDFieldName). // descending
Limit(3).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Verify columns
s.True(result.HasColumn(types.IDFieldName))
s.True(result.HasColumn(types.ScoreFieldName))
s.True(result.HasColumn("age"))
s.False(result.HasColumn("name"))
}
// =============================================================================
// Memory Leak Tests
// =============================================================================
func (s *ChainTestSuite) TestMemoryLeak_ChainedOperations() {
for range 10 {
df := s.createTestDataFrame()
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Map(&MockAddColumnFunction{value: 1}, []string{types.IDFieldName}, []string{"temp"}).
Select(types.IDFieldName, "age", "temp").
Limit(3).
Execute(df)
s.Require().NoError(err)
result.Release()
df.Release()
}
// Memory leak check happens in TearDownTest
}
func (s *ChainTestSuite) TestMemoryLeak_FilterOperation() {
for range 10 {
df := s.createTestDataFrame()
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Filter(&MockFilterFunction{threshold: 0.5}, []string{types.ScoreFieldName}).
Execute(df)
s.Require().NoError(err)
result.Release()
df.Release()
}
// Memory leak check happens in TearDownTest
}
func (s *ChainTestSuite) TestMemoryLeak_SortOperation() {
for range 10 {
df := s.createTestDataFrame()
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Sort("age", true, types.IDFieldName).
Execute(df)
s.Require().NoError(err)
result.Release()
df.Release()
}
// Memory leak check happens in TearDownTest
}
// =============================================================================
// Error Path Memory Leak Tests
// =============================================================================
// MockErrorFunction is a function that returns an error during execution
type MockErrorFunction struct {
errorMsg string
}
func (f *MockErrorFunction) Name() string { return "MockError" }
func (f *MockErrorFunction) OutputDataTypes() []arrow.DataType {
return []arrow.DataType{arrow.PrimitiveTypes.Int64}
}
func (f *MockErrorFunction) IsRunnable(stage string) bool { return true }
func (f *MockErrorFunction) Execute(ctx *types.FuncContext, inputs []*arrow.Chunked) ([]*arrow.Chunked, error) {
return nil, fmt.Errorf("%s", f.errorMsg)
}
// MockPartialSuccessFunction creates output but the second Map in chain will fail
// This tests cleanup when function succeeds but subsequent operations fail
type MockPartialSuccessFunction struct {
value int64
}
func (f *MockPartialSuccessFunction) Name() string { return "MockPartialSuccess" }
func (f *MockPartialSuccessFunction) OutputDataTypes() []arrow.DataType {
return []arrow.DataType{arrow.PrimitiveTypes.Int64}
}
func (f *MockPartialSuccessFunction) IsRunnable(stage string) bool { return true }
func (f *MockPartialSuccessFunction) Execute(ctx *types.FuncContext, inputs []*arrow.Chunked) ([]*arrow.Chunked, error) {
idCol := inputs[0]
chunks := make([]arrow.Array, len(idCol.Chunks()))
for i, chunk := range idCol.Chunks() {
builder := array.NewInt64Builder(ctx.Pool())
for range chunk.Len() {
builder.Append(f.value)
}
chunks[i] = builder.NewArray()
builder.Release()
}
result := arrow.NewChunked(arrow.PrimitiveTypes.Int64, chunks)
for _, chunk := range chunks {
chunk.Release()
}
return []*arrow.Chunked{result}, nil
}
func (s *ChainTestSuite) TestMemoryLeak_MapOpError_FunctionFails() {
// Test: function execution fails, should not leak memory
for range 10 {
df := s.createTestDataFrame()
_, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Map(&MockErrorFunction{errorMsg: "intentional error"}, []string{types.IDFieldName}, []string{"output"}).
Execute(df)
s.Require().Error(err)
s.Contains(err.Error(), "intentional error")
df.Release()
}
// Memory leak check happens in TearDownTest
}
func (s *ChainTestSuite) TestMemoryLeak_MapOpError_NonExistentInputColumn() {
// Test: MapOp fails because input column doesn't exist
for range 10 {
df := s.createTestDataFrame()
_, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Map(&MockAddColumnFunction{value: 1}, []string{"non_existent_column"}, []string{"output"}).
Execute(df)
s.Require().Error(err)
df.Release()
}
// Memory leak check happens in TearDownTest
}
func (s *ChainTestSuite) TestMemoryLeak_MapOpError_ChainedMapFirstSucceedsSecondFails() {
// Test: First Map succeeds, second Map fails - should cleanup first Map's result
for range 10 {
df := s.createTestDataFrame()
_, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Map(&MockPartialSuccessFunction{value: 1}, []string{types.IDFieldName}, []string{"temp1"}).
Map(&MockErrorFunction{errorMsg: "second map fails"}, []string{"temp1"}, []string{"temp2"}).
Execute(df)
s.Require().Error(err)
df.Release()
}
// Memory leak check happens in TearDownTest
}
func (s *ChainTestSuite) TestMemoryLeak_FilterOpError_FunctionFails() {
// Test: Filter function fails, should not leak memory
for range 10 {
df := s.createTestDataFrame()
_, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Filter(&MockErrorFilterFunction{errorMsg: "filter error"}, []string{types.ScoreFieldName}).
Execute(df)
s.Require().Error(err)
df.Release()
}
// Memory leak check happens in TearDownTest
}
func (s *ChainTestSuite) TestMemoryLeak_FilterOpError_NonExistentColumn() {
// Test: Filter fails because input column doesn't exist
for range 10 {
df := s.createTestDataFrame()
_, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Filter(&MockFilterFunction{threshold: 0.5}, []string{"non_existent_column"}).
Execute(df)
s.Require().Error(err)
df.Release()
}
// Memory leak check happens in TearDownTest
}
func (s *ChainTestSuite) TestMemoryLeak_SelectOpError_NonExistentColumn() {
// Test: Select fails because column doesn't exist
for range 10 {
df := s.createTestDataFrame()
_, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Select(types.IDFieldName, "non_existent_column").
Execute(df)
s.Require().Error(err)
df.Release()
}
// Memory leak check happens in TearDownTest
}
func (s *ChainTestSuite) TestMemoryLeak_SortOpError_NonExistentColumn() {
// Test: Sort fails because column doesn't exist
for range 10 {
df := s.createTestDataFrame()
_, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Sort("non_existent_column", true, types.IDFieldName).
Execute(df)
s.Require().Error(err)
df.Release()
}
// Memory leak check happens in TearDownTest
}
func (s *ChainTestSuite) TestMemoryLeak_ChainedError_MiddleOperatorFails() {
// Test: Chain with multiple operators, middle one fails
for range 10 {
df := s.createTestDataFrame()
_, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Map(&MockAddColumnFunction{value: 1}, []string{types.IDFieldName}, []string{"temp"}).
Select("non_existent"). // This will fail
Limit(5).
Execute(df)
s.Require().Error(err)
df.Release()
}
// Memory leak check happens in TearDownTest
}
func (s *ChainTestSuite) TestMemoryLeak_ContextCancellation() {
// Test: Context cancellation during chain execution
for range 10 {
df := s.createTestDataFrame()
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
_, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Map(&MockAddColumnFunction{value: 1}, []string{types.IDFieldName}, []string{"temp"}).
ExecuteWithContext(ctx, df)
s.Require().Error(err)
df.Release()
}
// Memory leak check happens in TearDownTest
}
// MockErrorFilterFunction is a filter function that returns an error
type MockErrorFilterFunction struct {
errorMsg string
}
func (f *MockErrorFilterFunction) Name() string { return "MockErrorFilter" }
func (f *MockErrorFilterFunction) OutputDataTypes() []arrow.DataType {
return []arrow.DataType{arrow.FixedWidthTypes.Boolean}
}
func (f *MockErrorFilterFunction) IsRunnable(stage string) bool { return true }
func (f *MockErrorFilterFunction) Execute(ctx *types.FuncContext, inputs []*arrow.Chunked) ([]*arrow.Chunked, error) {
return nil, fmt.Errorf("%s", f.errorMsg)
}
// MockNilOutputFunction returns one valid output and one nil output
// This triggers the error path in MapOp after some memory has been allocated
type MockNilOutputFunction struct{}
func (f *MockNilOutputFunction) Name() string { return "MockNilOutput" }
func (f *MockNilOutputFunction) OutputDataTypes() []arrow.DataType {
return []arrow.DataType{arrow.PrimitiveTypes.Int64, arrow.PrimitiveTypes.Int64}
}
func (f *MockNilOutputFunction) IsRunnable(stage string) bool { return true }
func (f *MockNilOutputFunction) Execute(ctx *types.FuncContext, inputs []*arrow.Chunked) ([]*arrow.Chunked, error) {
idCol := inputs[0]
// Create first output (valid)
chunks := make([]arrow.Array, len(idCol.Chunks()))
for i, chunk := range idCol.Chunks() {
builder := array.NewInt64Builder(ctx.Pool())
for range chunk.Len() {
builder.Append(1)
}
chunks[i] = builder.NewArray()
builder.Release()
}
result := arrow.NewChunked(arrow.PrimitiveTypes.Int64, chunks)
for _, chunk := range chunks {
chunk.Release()
}
// Return first output as valid, second as nil
// This will cause addChunkedColumnDirect to fail on the second output
return []*arrow.Chunked{result, nil}, nil
}
func (s *ChainTestSuite) TestMemoryLeak_MapOpError_NilOutputColumn() {
// Test: Function returns a nil output column, should cleanup properly
for range 10 {
df := s.createTestDataFrame()
_, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Map(&MockNilOutputFunction{}, []string{types.IDFieldName}, []string{"out1", "out2"}).
Execute(df)
s.Require().Error(err)
s.Contains(err.Error(), "nil")
df.Release()
}
// Memory leak check happens in TearDownTest
}
// =============================================================================
// Operator String Tests
// =============================================================================
func (s *ChainTestSuite) TestOperatorStrings() {
// MapOp
mapOp := &MapOp{function: &MockAddColumnFunction{value: 1}}
s.Contains(mapOp.String(), "Map(MockAddColumn)")
mapOpNil := &MapOp{function: nil}
s.Equal("Map(nil)", mapOpNil.String())
// FilterOp
filterOp, _ := NewFilterOp(&MockFilterFunction{threshold: 0.5}, []string{"score"})
s.Equal("Filter(MockFilter)", filterOp.String())
// SelectOp
selectOp := NewSelectOp([]string{"a", "b"})
s.Contains(selectOp.String(), "Select")
// SortOp
sortOpAsc := newSortOp("col", false, types.IDFieldName)
s.Equal("Sort(col ASC, $id ASC)", sortOpAsc.String())
sortOpDesc := newSortOp("col", true, types.IDFieldName)
s.Equal("Sort(col DESC, $id ASC)", sortOpDesc.String())
// LimitOp
limitOp := NewLimitOp(10, 0)
s.Equal("Limit(10)", limitOp.String())
limitOpOffset := NewLimitOp(10, 5)
s.Equal("Limit(10, offset=5)", limitOpOffset.String())
}
// =============================================================================
// FuncContext Tests
// =============================================================================
func (s *ChainTestSuite) TestNewFuncContext() {
ctx := types.NewFuncContext(s.pool)
s.Equal(s.pool, ctx.Pool())
}
func (s *ChainTestSuite) TestNewFuncContext_NilPool() {
// nil pool should use DefaultAllocator
ctx := types.NewFuncContext(nil)
s.NotNil(ctx)
s.Equal(memory.DefaultAllocator, ctx.Pool())
}
// =============================================================================
// Edge Cases
// =============================================================================
func (s *ChainTestSuite) TestEmptyChain() {
df := s.createTestDataFrame()
defer df.Release()
// Empty chain should return input as-is
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Execute(df)
s.Require().NoError(err)
// Result should be the same as input
s.Equal(df, result)
}
func (s *ChainTestSuite) TestSelectOp_AllColumns() {
df := s.createTestDataFrame()
defer df.Release()
// Select all columns
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Select(types.IDFieldName, types.ScoreFieldName, "age", "name").
Execute(df)
s.Require().NoError(err)
defer result.Release()
s.Equal(df.NumColumns(), result.NumColumns())
s.Equal(df.NumRows(), result.NumRows())
}
func (s *ChainTestSuite) TestLimitOp_ZeroLimit() {
df := s.createTestDataFrame()
defer df.Release()
_, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Limit(0).
Execute(df)
s.Require().Error(err)
s.Contains(err.Error(), "limit must be positive")
}
// =============================================================================
// Validate Tests
// =============================================================================
func (s *ChainTestSuite) TestValidate_ValidChain() {
fc := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Select(types.IDFieldName, "age").
Sort("age", false, types.IDFieldName).
Limit(10)
err := fc.Validate()
s.NoError(err)
}
func (s *ChainTestSuite) TestValidate_NilMapFunction() {
fc := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Map(nil, []string{}, []string{})
err := fc.Validate()
s.Error(err)
s.Contains(err.Error(), "chain build error")
}
func (s *ChainTestSuite) TestValidate_BuildError() {
fc := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank)
// Force a build error by adding a map with nil function
fc.Map(nil, []string{"a"}, []string{"b"})
err := fc.Validate()
s.Error(err)
}
func (s *ChainTestSuite) TestValidate_MissingStage() {
// Chain without stage should fail validation
fc := NewFuncChainWithAllocator(s.pool).
Select(types.IDFieldName, "age").
Limit(10)
err := fc.Validate()
s.Error(err)
s.Contains(err.Error(), "chain stage is required")
}
func (s *ChainTestSuite) TestValidate_MergeOpNotAtIndex0() {
// MergeOp at index > 0 should fail validation
mergeOp := NewMergeOp(MergeStrategyMax)
fc := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Limit(10).
Add(mergeOp)
err := fc.Validate()
s.Error(err)
s.Contains(err.Error(), "MergeOp can only be the first operator")
}
// =============================================================================
// MapWithError Tests
// =============================================================================
func (s *ChainTestSuite) TestMapWithError_Success() {
df := s.createTestDataFrame()
defer df.Release()
fc := NewFuncChainWithAllocator(s.pool).SetStage(types.StageL2Rerank)
_, err := fc.MapWithError(&MockAddColumnFunction{value: 42}, []string{types.IDFieldName}, []string{"constant"})
s.NoError(err)
result, err := fc.Execute(df)
s.Require().NoError(err)
defer result.Release()
s.True(result.HasColumn("constant"))
}
func (s *ChainTestSuite) TestMapWithError_NilFunction() {
fc := NewFuncChainWithAllocator(s.pool).SetStage(types.StageL2Rerank)
_, err := fc.MapWithError(nil, []string{types.IDFieldName}, []string{"out"})
s.Error(err)
}
// =============================================================================
// ExecuteWithStage Tests
// =============================================================================
// MockStagedFunction is a function that only runs in certain stages
type MockStagedFunction struct {
value int64
stages []string
}
func (f *MockStagedFunction) Name() string { return "MockStaged" }
func (f *MockStagedFunction) OutputDataTypes() []arrow.DataType {
return []arrow.DataType{arrow.PrimitiveTypes.Int64}
}
func (f *MockStagedFunction) IsRunnable(stage string) bool {
for _, s := range f.stages {
if s == stage {
return true
}
}
return false
}
func (f *MockStagedFunction) Execute(ctx *types.FuncContext, inputs []*arrow.Chunked) ([]*arrow.Chunked, error) {
idCol := inputs[0]
chunks := make([]arrow.Array, len(idCol.Chunks()))
for i, chunk := range idCol.Chunks() {
builder := array.NewInt64Builder(ctx.Pool())
for range chunk.Len() {
builder.Append(f.value)
}
chunks[i] = builder.NewArray()
builder.Release()
}
result := arrow.NewChunked(arrow.PrimitiveTypes.Int64, chunks)
for _, chunk := range chunks {
chunk.Release()
}
return []*arrow.Chunked{result}, nil
}
func (s *ChainTestSuite) TestExecuteWithStage_UnsupportedStage() {
df := s.createTestDataFrame()
defer df.Release()
// Create a function that only runs in "L2_rerank" stage
stagedFn := &MockStagedFunction{value: 999, stages: []string{types.StageL2Rerank}}
// Execute with a different stage should return an error
_, err := NewFuncChainWithAllocator(s.pool).
Map(stagedFn, []string{types.IDFieldName}, []string{"staged_col"}).
SetStage(types.StageL1Rerank). // Different stage, should error
Execute(df)
s.Require().Error(err)
s.Contains(err.Error(), "does not support stage")
}
func (s *ChainTestSuite) TestExecuteWithStage_RunOperator() {
df := s.createTestDataFrame()
defer df.Release()
// Create a function that only runs in "L2_rerank" stage
stagedFn := &MockStagedFunction{value: 999, stages: []string{types.StageL2Rerank}}
result, err := NewFuncChainWithAllocator(s.pool).
Map(stagedFn, []string{types.IDFieldName}, []string{"staged_col"}).
SetStage(types.StageL2Rerank). // Same stage, should run
Execute(df)
s.Require().NoError(err)
defer result.Release()
// The staged column should exist
s.True(result.HasColumn("staged_col"))
}
func (s *ChainTestSuite) TestExecuteWithStage_EmptyStage() {
df := s.createTestDataFrame()
defer df.Release()
// Create a function that only runs in specific stages
stagedFn := &MockStagedFunction{value: 999, stages: []string{types.StageL2Rerank}}
// Empty stage should cause an error
_, err := NewFuncChainWithAllocator(s.pool).
Map(stagedFn, []string{types.IDFieldName}, []string{"staged_col"}).
SetStage(""). // Empty stage should cause error
Execute(df)
s.Require().Error(err)
s.Contains(err.Error(), "stage is required")
}
// =============================================================================
// FilterOp Type Validation Tests
// =============================================================================
func (s *ChainTestSuite) TestFilterOp_NonBooleanFunction() {
// Try to create FilterOp with a function that returns non-boolean type
_, err := NewFilterOp(&MockAddColumnFunction{value: 1}, []string{"score"})
s.Error(err)
s.Contains(err.Error(), "must return boolean type")
}
// =============================================================================
// FuncContext Stage Tests
// =============================================================================
func (s *ChainTestSuite) TestNewFuncContextWithStage() {
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
s.Equal(s.pool, ctx.Pool())
s.Equal(types.StageL2Rerank, ctx.Stage())
}
func (s *ChainTestSuite) TestFuncContextStage_Empty() {
ctx := types.NewFuncContext(s.pool)
s.Equal("", ctx.Stage())
}
// =============================================================================
// FuncContext Context Tests
// =============================================================================
func (s *ChainTestSuite) TestNewFuncContextWithContext() {
goCtx := context.Background()
ctx := types.NewFuncContextWithContext(goCtx, s.pool)
s.Equal(s.pool, ctx.Pool())
s.Equal(goCtx, ctx.Context())
}
func (s *ChainTestSuite) TestNewFuncContextWithContext_NilContext() {
ctx := types.NewFuncContextWithContext(context.TODO(), s.pool)
s.NotNil(ctx.Context())
}
func (s *ChainTestSuite) TestNewFuncContextFull() {
goCtx := context.Background()
ctx := types.NewFuncContextFull(goCtx, s.pool, types.StageL2Rerank)
s.Equal(s.pool, ctx.Pool())
s.Equal(goCtx, ctx.Context())
s.Equal(types.StageL2Rerank, ctx.Stage())
}
func (s *ChainTestSuite) TestFuncContext_ContextMethod() {
ctx := types.NewFuncContext(s.pool)
s.NotNil(ctx.Context())
}
// =============================================================================
// FuncChain Stage Tests
// =============================================================================
func (s *ChainTestSuite) TestFuncChain_SetStage() {
fc := NewFuncChainWithAllocator(s.pool).SetStage(types.StageL2Rerank)
s.Equal(types.StageL2Rerank, fc.Stage())
}
func (s *ChainTestSuite) TestFuncChain_Validate_WithStage() {
df := s.createTestDataFrame()
defer df.Release()
// Create a function that only supports L2_rerank stage
stagedFn := &MockStagedFunction{value: 999, stages: []string{types.StageL2Rerank}}
// Set stage in chain - validation should fail for unsupported stage
fc := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL1Rerank).
Map(stagedFn, []string{types.IDFieldName}, []string{"staged_col"})
// Execute should fail because Validate checks stage compatibility
_, err := fc.Execute(df)
s.Error(err)
s.Contains(err.Error(), "does not support stage")
}
func (s *ChainTestSuite) TestExecuteWithContext_Cancellation() {
df := s.createTestDataFrame()
defer df.Release()
// Create a canceled context
goCtx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
// Execute with canceled context
_, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Select(types.IDFieldName, "age").
Sort("age", false, types.IDFieldName).
Limit(10).
ExecuteWithContext(goCtx, df)
s.Error(err)
s.Equal(context.Canceled, err)
}
func (s *ChainTestSuite) TestExecuteWithContext_Success() {
df := s.createTestDataFrame()
defer df.Release()
goCtx := context.Background()
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Select(types.IDFieldName, "age").
Sort("age", false, types.IDFieldName).
Limit(2).
ExecuteWithContext(goCtx, df)
s.NoError(err)
defer result.Release()
// Limit applies per chunk: 2 chunks with limit 2 each = 4 rows total
s.Equal(int64(4), result.NumRows())
}
// =============================================================================
// Registry Tests
// =============================================================================
func (s *ChainTestSuite) TestFunctionRegistry() {
registry := types.NewFunctionRegistry()
s.NotNil(registry)
// Test Register and Has
factory := func(_ types.FunctionBuildContext, _ types.FunctionConfig) (types.FunctionExpr, error) {
return &MockAddColumnFunction{value: 1}, nil
}
err := registry.Register("test_func", factory)
s.NoError(err)
s.True(registry.Has("test_func"))
s.False(registry.Has("nonexistent"))
// Test duplicate registration returns error
err = registry.Register("test_func", factory)
s.Error(err)
s.Contains(err.Error(), "already registered")
// Test empty name returns error
err = registry.Register("", factory)
s.Error(err)
s.Contains(err.Error(), "cannot be empty")
// Test nil factory returns error
err = registry.Register("nil_factory", nil)
s.Error(err)
s.Contains(err.Error(), "cannot be nil")
// Test Get
f, ok := registry.Get("test_func")
s.True(ok)
s.NotNil(f)
_, ok = registry.Get("nonexistent")
s.False(ok)
// Test Create
expr, err := registry.Create(types.FunctionBuildContext{}, types.FunctionConfig{Name: "test_func"})
s.NoError(err)
s.NotNil(expr)
_, err = registry.Create(types.FunctionBuildContext{}, types.FunctionConfig{Name: "nonexistent"})
s.Error(err)
// Test Names
names := registry.Names()
s.Contains(names, "test_func")
// Test MustRegister panics on duplicate
s.Panics(func() {
registry.MustRegister("test_func", factory)
})
}
func (s *ChainTestSuite) TestGlobalFunctionRegistry() {
// Use a unique name to avoid conflicts with other tests
funcName := "test_global_func_" + s.T().Name()
// Register a test function first
err := types.RegisterFunction(funcName, func(_ types.FunctionBuildContext, _ types.FunctionConfig) (types.FunctionExpr, error) {
return &MockAddColumnFunction{value: 42}, nil
})
s.NoError(err)
// Test global registry functions
s.True(types.HasFunction(funcName))
s.False(types.HasFunction("nonexistent_function"))
// Test FunctionNames
names := types.FunctionNames()
s.Contains(names, funcName)
// Test GetFunctionFactory
factory, ok := types.GetFunctionFactory(funcName)
s.True(ok)
s.NotNil(factory)
_, ok = types.GetFunctionFactory("nonexistent")
s.False(ok)
// Test CreateFunction
expr, err := types.CreateFunction(types.FunctionBuildContext{}, types.FunctionConfig{Name: funcName})
s.NoError(err)
s.NotNil(expr)
_, err = types.CreateFunction(types.FunctionBuildContext{}, types.FunctionConfig{Name: "nonexistent"})
s.Error(err)
// Test duplicate registration returns error
err = types.RegisterFunction(funcName, func(_ types.FunctionBuildContext, _ types.FunctionConfig) (types.FunctionExpr, error) {
return nil, nil
})
s.Error(err)
s.Contains(err.Error(), "already registered")
// Test MustRegisterFunction panics on duplicate
s.Panics(func() {
types.MustRegisterFunction(funcName, func(_ types.FunctionBuildContext, _ types.FunctionConfig) (types.FunctionExpr, error) {
return nil, nil
})
})
}
// =============================================================================
// Operator Inputs/Outputs Tests
// =============================================================================
func (s *ChainTestSuite) TestOperatorInputsOutputs() {
// BaseOp
baseOp := &BaseOp{}
s.Nil(baseOp.Inputs())
s.Nil(baseOp.Outputs())
// MapOp
mapOp, _ := NewMapOp(&MockAddColumnFunction{value: 1}, []string{"a", "b"}, []string{"c"})
s.Equal([]string{"a", "b"}, mapOp.Inputs())
s.Equal([]string{"c"}, mapOp.Outputs())
// FilterOp
filterOp, _ := NewFilterOp(&MockFilterFunction{threshold: 0.5}, []string{"filter_col"})
s.Equal([]string{"filter_col"}, filterOp.Inputs())
s.Empty(filterOp.Outputs())
// SelectOp
selectOp := NewSelectOp([]string{"a", "b", "c"})
s.Equal([]string{"a", "b", "c"}, selectOp.Inputs())
s.Equal([]string{"a", "b", "c"}, selectOp.Outputs())
// SortOp
sortOp := newSortOp("sort_col", true, types.IDFieldName)
s.Equal([]string{"sort_col", types.IDFieldName}, sortOp.Inputs())
s.Empty(sortOp.Outputs())
sortOpWithTieBreak := newSortOp("sort_col", true, "tie_col")
s.Equal([]string{"sort_col", "tie_col"}, sortOpWithTieBreak.Inputs())
s.Empty(sortOpWithTieBreak.Outputs())
// LimitOp
limitOp := NewLimitOp(10, 5)
s.Empty(limitOp.Inputs())
s.Empty(limitOp.Outputs())
}
// =============================================================================
// compareArrayValues Tests
// =============================================================================
func (s *ChainTestSuite) TestCompareArrayValues_AllTypes() {
// Test Int64
int64Builder := array.NewInt64Builder(s.pool)
int64Builder.AppendValues([]int64{10, 20, 10}, nil)
int64Arr := int64Builder.NewArray()
int64Builder.Release()
defer int64Arr.Release()
s.Equal(-1, compareArrayValues(int64Arr, 0, 1)) // 10 < 20
s.Equal(1, compareArrayValues(int64Arr, 1, 0)) // 20 > 10
s.Equal(0, compareArrayValues(int64Arr, 0, 2)) // 10 == 10
// Test Float32
float32Builder := array.NewFloat32Builder(s.pool)
float32Builder.AppendValues([]float32{1.5, 2.5, 1.5}, nil)
float32Arr := float32Builder.NewArray()
float32Builder.Release()
defer float32Arr.Release()
s.Equal(-1, compareArrayValues(float32Arr, 0, 1))
s.Equal(1, compareArrayValues(float32Arr, 1, 0))
s.Equal(0, compareArrayValues(float32Arr, 0, 2))
// Test Float64
float64Builder := array.NewFloat64Builder(s.pool)
float64Builder.AppendValues([]float64{1.5, 2.5}, nil)
float64Arr := float64Builder.NewArray()
float64Builder.Release()
defer float64Arr.Release()
s.Equal(-1, compareArrayValues(float64Arr, 0, 1))
s.Equal(1, compareArrayValues(float64Arr, 1, 0))
// Test String
stringBuilder := array.NewStringBuilder(s.pool)
stringBuilder.AppendValues([]string{"apple", "banana", "apple"}, nil)
stringArr := stringBuilder.NewArray()
stringBuilder.Release()
defer stringArr.Release()
s.Equal(-1, compareArrayValues(stringArr, 0, 1)) // "apple" < "banana"
s.Equal(1, compareArrayValues(stringArr, 1, 0))
s.Equal(0, compareArrayValues(stringArr, 0, 2))
// Test Int8
int8Builder := array.NewInt8Builder(s.pool)
int8Builder.AppendValues([]int8{1, 2}, nil)
int8Arr := int8Builder.NewArray()
int8Builder.Release()
defer int8Arr.Release()
s.Equal(-1, compareArrayValues(int8Arr, 0, 1))
// Test Int16
int16Builder := array.NewInt16Builder(s.pool)
int16Builder.AppendValues([]int16{100, 200}, nil)
int16Arr := int16Builder.NewArray()
int16Builder.Release()
defer int16Arr.Release()
s.Equal(-1, compareArrayValues(int16Arr, 0, 1))
// Test Int32
int32Builder := array.NewInt32Builder(s.pool)
int32Builder.AppendValues([]int32{1000, 2000}, nil)
int32Arr := int32Builder.NewArray()
int32Builder.Release()
defer int32Arr.Release()
s.Equal(-1, compareArrayValues(int32Arr, 0, 1))
// Test with nulls
int64WithNullBuilder := array.NewInt64Builder(s.pool)
int64WithNullBuilder.AppendNull()
int64WithNullBuilder.Append(10)
int64WithNullBuilder.AppendNull()
int64WithNullArr := int64WithNullBuilder.NewArray()
int64WithNullBuilder.Release()
defer int64WithNullArr.Release()
s.Equal(0, compareArrayValues(int64WithNullArr, 0, 2)) // null == null
s.Equal(-1, compareArrayValues(int64WithNullArr, 0, 1)) // null < 10
s.Equal(1, compareArrayValues(int64WithNullArr, 1, 0)) // 10 > null
}
// =============================================================================
// dispatchPickByIndices Tests
// =============================================================================
func (s *ChainTestSuite) TestDispatchPickByIndices_AllTypes() {
indices := []int{2, 0, 1}
// Test Int8
int8Builder := array.NewInt8Builder(s.pool)
int8Builder.AppendValues([]int8{10, 20, 30}, nil)
int8Arr := int8Builder.NewArray()
int8Builder.Release()
defer int8Arr.Release()
result, err := dispatchPickByIndices(s.pool, int8Arr, indices)
s.Require().NoError(err)
defer result.Release()
s.Equal(int8(30), result.(*array.Int8).Value(0))
// Test Int16
int16Builder := array.NewInt16Builder(s.pool)
int16Builder.AppendValues([]int16{100, 200, 300}, nil)
int16Arr := int16Builder.NewArray()
int16Builder.Release()
defer int16Arr.Release()
result, err = dispatchPickByIndices(s.pool, int16Arr, indices)
s.Require().NoError(err)
defer result.Release()
s.Equal(int16(300), result.(*array.Int16).Value(0))
// Test Int32
int32Builder := array.NewInt32Builder(s.pool)
int32Builder.AppendValues([]int32{1000, 2000, 3000}, nil)
int32Arr := int32Builder.NewArray()
int32Builder.Release()
defer int32Arr.Release()
result, err = dispatchPickByIndices(s.pool, int32Arr, indices)
s.Require().NoError(err)
defer result.Release()
s.Equal(int32(3000), result.(*array.Int32).Value(0))
// Test Float64
float64Builder := array.NewFloat64Builder(s.pool)
float64Builder.AppendValues([]float64{1.1, 2.2, 3.3}, nil)
float64Arr := float64Builder.NewArray()
float64Builder.Release()
defer float64Arr.Release()
result, err = dispatchPickByIndices(s.pool, float64Arr, indices)
s.Require().NoError(err)
defer result.Release()
s.InDelta(3.3, result.(*array.Float64).Value(0), 0.001)
// Test Boolean
boolBuilder := array.NewBooleanBuilder(s.pool)
boolBuilder.AppendValues([]bool{true, false, true}, nil)
boolArr := boolBuilder.NewArray()
boolBuilder.Release()
defer boolArr.Release()
result, err = dispatchPickByIndices(s.pool, boolArr, indices)
s.Require().NoError(err)
defer result.Release()
s.True(result.(*array.Boolean).Value(0))
}
// =============================================================================
// SortOp with different types Tests
// =============================================================================
func (s *ChainTestSuite) TestSortOp_FloatColumn() {
// Create DataFrame with float scores
resultData := &schemapb.SearchResultData{
NumQueries: 1,
TopK: 3,
Topks: []int64{3},
Scores: []float32{0.5, 0.9, 0.1},
Ids: &schemapb.IDs{
IdField: &schemapb.IDs_IntId{
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3}},
},
},
}
df, err := FromSearchResultData(resultData, s.pool, nil)
s.Require().NoError(err)
defer df.Release()
// Sort by score descending
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Sort(types.ScoreFieldName, true, types.IDFieldName).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Verify order: should be 0.9, 0.5, 0.1
scoreCol := result.Column(types.ScoreFieldName)
chunk := scoreCol.Chunk(0).(*array.Float32)
s.Equal(float32(0.9), chunk.Value(0))
s.Equal(float32(0.5), chunk.Value(1))
s.Equal(float32(0.1), chunk.Value(2))
}
func (s *ChainTestSuite) TestSortOp_StringColumn() {
resultData := &schemapb.SearchResultData{
NumQueries: 1,
TopK: 3,
Topks: []int64{3},
Ids: &schemapb.IDs{
IdField: &schemapb.IDs_IntId{
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3}},
},
},
FieldsData: []*schemapb.FieldData{
{
Type: schemapb.DataType_VarChar,
FieldName: "name",
FieldId: 100,
Field: &schemapb.FieldData_Scalars{
Scalars: &schemapb.ScalarField{
Data: &schemapb.ScalarField_StringData{
StringData: &schemapb.StringArray{Data: []string{"charlie", "alice", "bob"}},
},
},
},
},
},
}
df, err := FromSearchResultData(resultData, s.pool, []string{"name"})
s.Require().NoError(err)
defer df.Release()
// Sort by name ascending
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Sort("name", false, types.IDFieldName).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Verify order: should be alice, bob, charlie
nameCol := result.Column("name")
chunk := nameCol.Chunk(0).(*array.String)
s.Equal("alice", chunk.Value(0))
s.Equal("bob", chunk.Value(1))
s.Equal("charlie", chunk.Value(2))
}
func (s *ChainTestSuite) TestSortOp_AllColumnsReordered() {
// Create DataFrame where age is NOT already sorted
// This tests that all columns are reordered together, not just the sort column
resultData := &schemapb.SearchResultData{
NumQueries: 1,
TopK: 4,
Topks: []int64{4},
Scores: []float32{0.1, 0.2, 0.3, 0.4}, // will become [0.3, 0.1, 0.4, 0.2] after sort
Ids: &schemapb.IDs{
IdField: &schemapb.IDs_IntId{
IntId: &schemapb.LongArray{Data: []int64{101, 102, 103, 104}}, // will become [103, 101, 104, 102]
},
},
FieldsData: []*schemapb.FieldData{
{
Type: schemapb.DataType_Int64,
FieldName: "age",
FieldId: 100,
Field: &schemapb.FieldData_Scalars{
Scalars: &schemapb.ScalarField{
Data: &schemapb.ScalarField_LongData{
LongData: &schemapb.LongArray{Data: []int64{30, 10, 40, 20}}, // unsorted!
},
},
},
},
{
Type: schemapb.DataType_VarChar,
FieldName: "name",
FieldId: 101,
Field: &schemapb.FieldData_Scalars{
Scalars: &schemapb.ScalarField{
Data: &schemapb.ScalarField_StringData{
StringData: &schemapb.StringArray{Data: []string{"bob", "alice", "david", "charlie"}},
},
},
},
},
},
}
df, err := FromSearchResultData(resultData, s.pool, []string{"age", "name"})
s.Require().NoError(err)
defer df.Release()
// Sort by age ascending
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Sort("age", false, types.IDFieldName).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Original data:
// age: [30, 10, 40, 20]
// name: [bob, alice, david, charlie]
// $id: [101, 102, 103, 104]
// $score: [0.1, 0.2, 0.3, 0.4]
//
// After sorting by age ascending, expected:
// age: [10, 20, 30, 40]
// name: [alice, charlie, bob, david]
// $id: [102, 104, 101, 103]
// $score: [0.2, 0.4, 0.1, 0.3]
// Verify age column is sorted
ageCol := result.Column("age")
ageChunk := ageCol.Chunk(0).(*array.Int64)
s.Equal(int64(10), ageChunk.Value(0))
s.Equal(int64(20), ageChunk.Value(1))
s.Equal(int64(30), ageChunk.Value(2))
s.Equal(int64(40), ageChunk.Value(3))
// Verify name column is reordered accordingly
nameCol := result.Column("name")
nameChunk := nameCol.Chunk(0).(*array.String)
s.Equal("alice", nameChunk.Value(0)) // age=10
s.Equal("charlie", nameChunk.Value(1)) // age=20
s.Equal("bob", nameChunk.Value(2)) // age=30
s.Equal("david", nameChunk.Value(3)) // age=40
// Verify $id column is reordered accordingly
idCol := result.Column(types.IDFieldName)
idChunk := idCol.Chunk(0).(*array.Int64)
s.Equal(int64(102), idChunk.Value(0)) // age=10
s.Equal(int64(104), idChunk.Value(1)) // age=20
s.Equal(int64(101), idChunk.Value(2)) // age=30
s.Equal(int64(103), idChunk.Value(3)) // age=40
// Verify $score column is reordered accordingly
scoreCol := result.Column(types.ScoreFieldName)
scoreChunk := scoreCol.Chunk(0).(*array.Float32)
s.Equal(float32(0.2), scoreChunk.Value(0)) // age=10
s.Equal(float32(0.4), scoreChunk.Value(1)) // age=20
s.Equal(float32(0.1), scoreChunk.Value(2)) // age=30
s.Equal(float32(0.3), scoreChunk.Value(3)) // age=40
}
func (s *ChainTestSuite) TestSortOp_MultipleChunksAllColumnsReordered() {
// Test with multiple chunks to ensure each chunk is sorted independently
// and all columns within each chunk are reordered together
resultData := &schemapb.SearchResultData{
NumQueries: 2,
TopK: 3,
Topks: []int64{3, 3},
Scores: []float32{0.1, 0.2, 0.3, 0.4, 0.5, 0.6},
Ids: &schemapb.IDs{
IdField: &schemapb.IDs_IntId{
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3, 4, 5, 6}},
},
},
FieldsData: []*schemapb.FieldData{
{
Type: schemapb.DataType_Int64,
FieldName: "value",
FieldId: 100,
Field: &schemapb.FieldData_Scalars{
Scalars: &schemapb.ScalarField{
Data: &schemapb.ScalarField_LongData{
// Chunk 0: [30, 10, 20], Chunk 1: [60, 40, 50]
LongData: &schemapb.LongArray{Data: []int64{30, 10, 20, 60, 40, 50}},
},
},
},
},
},
}
df, err := FromSearchResultData(resultData, s.pool, []string{"value"})
s.Require().NoError(err)
defer df.Release()
// Sort by value ascending
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Sort("value", false, types.IDFieldName).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Chunk 0: value [30,10,20] -> [10,20,30], $id [1,2,3] -> [2,3,1]
// Chunk 1: value [60,40,50] -> [40,50,60], $id [4,5,6] -> [5,6,4]
valueCol := result.Column("value")
idCol := result.Column(types.IDFieldName)
// Verify chunk 0
valueChunk0 := valueCol.Chunk(0).(*array.Int64)
idChunk0 := idCol.Chunk(0).(*array.Int64)
s.Equal(int64(10), valueChunk0.Value(0))
s.Equal(int64(20), valueChunk0.Value(1))
s.Equal(int64(30), valueChunk0.Value(2))
s.Equal(int64(2), idChunk0.Value(0)) // corresponds to value=10
s.Equal(int64(3), idChunk0.Value(1)) // corresponds to value=20
s.Equal(int64(1), idChunk0.Value(2)) // corresponds to value=30
// Verify chunk 1
valueChunk1 := valueCol.Chunk(1).(*array.Int64)
idChunk1 := idCol.Chunk(1).(*array.Int64)
s.Equal(int64(40), valueChunk1.Value(0))
s.Equal(int64(50), valueChunk1.Value(1))
s.Equal(int64(60), valueChunk1.Value(2))
s.Equal(int64(5), idChunk1.Value(0)) // corresponds to value=40
s.Equal(int64(6), idChunk1.Value(1)) // corresponds to value=50
s.Equal(int64(4), idChunk1.Value(2)) // corresponds to value=60
}
// =============================================================================
// MapOp Name Tests
// =============================================================================
func (s *ChainTestSuite) TestMapOp_Name() {
mapOp, _ := NewMapOp(&MockAddColumnFunction{value: 1}, []string{"a"}, []string{"b"})
s.Equal("Map", mapOp.Name())
}
// =============================================================================
// FuncChain String with operators
// =============================================================================
func (s *ChainTestSuite) TestFuncChain_StringWithOperators() {
fc := NewFuncChainWithAllocator(nil).
SetName("test-chain").
Select("a", "b").
Filter(&MockFilterFunction{threshold: 0.5}, []string{"score"}).
Sort("s", true, types.IDFieldName).
Limit(10)
str := fc.String()
s.Contains(str, "test-chain")
s.Contains(str, "Select")
s.Contains(str, "Filter")
s.Contains(str, "Sort")
s.Contains(str, "Limit")
}
// =============================================================================
// MergeOp Test Suite
// =============================================================================
type MergeOpTestSuite struct {
suite.Suite
pool *memory.CheckedAllocator
}
func (s *MergeOpTestSuite) SetupTest() {
s.pool = memory.NewCheckedAllocator(memory.NewGoAllocator())
}
func (s *MergeOpTestSuite) TearDownTest() {
s.pool.AssertSize(s.T(), 0)
}
func TestMergeOpTestSuite(t *testing.T) {
suite.Run(t, new(MergeOpTestSuite))
}
// =============================================================================
// MergeOp Helper Functions
// =============================================================================
func (s *MergeOpTestSuite) createSearchResultData(ids []int64, scores []float32, topks []int64) *schemapb.SearchResultData {
return &schemapb.SearchResultData{
NumQueries: int64(len(topks)),
TopK: topks[0],
Topks: topks,
Scores: scores,
Ids: &schemapb.IDs{
IdField: &schemapb.IDs_IntId{
IntId: &schemapb.LongArray{
Data: ids,
},
},
},
FieldsData: []*schemapb.FieldData{},
}
}
func (s *MergeOpTestSuite) createDataFrame(ids []int64, scores []float32, topks []int64) *DataFrame {
resultData := s.createSearchResultData(ids, scores, topks)
df, err := FromSearchResultData(resultData, s.pool, nil)
s.Require().NoError(err)
return df
}
// =============================================================================
// MergeOp Tests
// =============================================================================
func (s *MergeOpTestSuite) TestNewMergeOp() {
// Test RRF strategy
op := NewMergeOp(MergeStrategyRRF, WithRRFK(60))
s.Equal(MergeStrategyRRF, op.strategy)
s.Equal(60.0, op.rrfK)
s.True(op.SortDescending()) // default: descending
// Test Weighted strategy without normalize
weights := []float64{0.3, 0.7}
op = NewMergeOp(MergeStrategyWeighted, WithWeights(weights), WithNormalize(false))
s.Equal(MergeStrategyWeighted, op.strategy)
s.Equal(weights, op.weights)
s.True(op.SortDescending()) // no metricTypes → default descending
// Test Max strategy
op = NewMergeOp(MergeStrategyMax)
s.Equal(MergeStrategyMax, op.strategy)
}
func (s *MergeOpTestSuite) TestMergeOpSingleInput() {
// Create single input DataFrame
df := s.createDataFrame(
[]int64{1, 2, 3},
[]float32{0.9, 0.8, 0.7},
[]int64{3},
)
defer df.Release()
// Create MergeOp
op := NewMergeOp(MergeStrategyMax,
WithMetricTypes([]string{"COSINE"}),
WithNormalize(true))
// Execute
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
result, err := op.ExecuteMulti(ctx, []*DataFrame{df})
s.Require().NoError(err)
defer result.Release()
// Verify result
s.Equal(1, result.NumChunks())
s.True(result.HasColumn(types.IDFieldName))
s.True(result.HasColumn(types.ScoreFieldName))
}
func (s *MergeOpTestSuite) TestMergeOpRRF() {
// Create two input DataFrames with overlapping IDs
df1 := s.createDataFrame(
[]int64{1, 2, 3},
[]float32{0.9, 0.8, 0.7},
[]int64{3},
)
defer df1.Release()
df2 := s.createDataFrame(
[]int64{2, 3, 4},
[]float32{0.95, 0.85, 0.75},
[]int64{3},
)
defer df2.Release()
// Create MergeOp with RRF strategy
op := NewMergeOp(MergeStrategyRRF,
WithRRFK(60),
WithMetricTypes([]string{"COSINE", "COSINE"}),
WithNormalize(true))
// Execute
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
// Verify result
s.Equal(1, result.NumChunks())
// IDs 2 and 3 should have higher scores because they appear in both lists
idCol := result.Column(types.IDFieldName)
s.NotNil(idCol)
s.GreaterOrEqual(idCol.Chunk(0).Len(), 3) // At least 3 unique IDs
}
func (s *MergeOpTestSuite) TestMergeOpWeighted() {
// Create two input DataFrames
df1 := s.createDataFrame(
[]int64{1, 2},
[]float32{1.0, 0.5},
[]int64{2},
)
defer df1.Release()
df2 := s.createDataFrame(
[]int64{2, 3},
[]float32{1.0, 0.5},
[]int64{2},
)
defer df2.Release()
// Create MergeOp with Weighted strategy
op := NewMergeOp(MergeStrategyWeighted,
WithWeights([]float64{0.3, 0.7}),
WithMetricTypes([]string{"COSINE", "COSINE"}),
WithNormalize(true))
// Execute
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
// Verify result
s.Equal(1, result.NumChunks())
// ID 2 appears in both lists, should have score = 0.3 * norm(0.5) + 0.7 * norm(1.0)
idCol := result.Column(types.IDFieldName)
s.NotNil(idCol)
s.Equal(3, idCol.Chunk(0).Len()) // 3 unique IDs: 1, 2, 3
}
func (s *MergeOpTestSuite) TestMergeOpMax() {
// Create two input DataFrames with overlapping IDs
df1 := s.createDataFrame(
[]int64{1, 2},
[]float32{0.5, 0.3},
[]int64{2},
)
defer df1.Release()
df2 := s.createDataFrame(
[]int64{1, 2},
[]float32{0.4, 0.6},
[]int64{2},
)
defer df2.Release()
// Create MergeOp with Max strategy
op := NewMergeOp(MergeStrategyMax,
WithMetricTypes([]string{"COSINE", "COSINE"}),
WithNormalize(true))
// Execute
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
// Verify: ID 1 should have max score, ID 2 should have max score
s.Equal(1, result.NumChunks())
s.Equal(int64(2), result.NumRows())
}
func (s *MergeOpTestSuite) TestMergeOpWeightsCountMismatch() {
df1 := s.createDataFrame([]int64{1}, []float32{0.9}, []int64{1})
defer df1.Release()
df2 := s.createDataFrame([]int64{2}, []float32{0.8}, []int64{1})
defer df2.Release()
// Weights count doesn't match inputs count
op := NewMergeOp(MergeStrategyWeighted,
WithWeights([]float64{0.5}), // Only 1 weight for 2 inputs
WithMetricTypes([]string{"COSINE", "COSINE"}))
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
_, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Error(err)
s.Contains(err.Error(), "weights count")
}
func (s *MergeOpTestSuite) TestMergeOpEmptyInput() {
op := NewMergeOp(MergeStrategyRRF)
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
_, err := op.ExecuteMulti(ctx, []*DataFrame{})
s.Error(err)
s.Contains(err.Error(), "no inputs")
}
func (s *MergeOpTestSuite) TestMergeOpMultipleChunks() {
// Create DataFrames with 2 chunks (2 queries)
df1 := s.createDataFrame(
[]int64{1, 2, 3, 4}, // Query 1: [1,2], Query 2: [3,4]
[]float32{0.9, 0.8, 0.7, 0.6},
[]int64{2, 2},
)
defer df1.Release()
df2 := s.createDataFrame(
[]int64{2, 5, 4, 6},
[]float32{0.95, 0.85, 0.75, 0.65},
[]int64{2, 2},
)
defer df2.Release()
op := NewMergeOp(MergeStrategyRRF,
WithRRFK(60),
WithMetricTypes([]string{"COSINE", "COSINE"}))
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
// Should have 2 chunks
s.Equal(2, result.NumChunks())
}
func (s *MergeOpTestSuite) TestMergeOpWeighted_MixedMetrics_NoNormalize() {
// L2 distances: smaller = better match. ID 1 has distance 0.1 (good), ID 2 has 2.0 (bad)
df1 := s.createDataFrame(
[]int64{1, 2},
[]float32{0.1, 2.0},
[]int64{2},
)
defer df1.Release()
// COSINE scores: larger = better. ID 1 has 0.95, ID 3 has 0.80
df2 := s.createDataFrame(
[]int64{1, 3},
[]float32{0.95, 0.80},
[]int64{2},
)
defer df2.Release()
op := NewMergeOp(MergeStrategyWeighted,
WithWeights([]float64{0.5, 0.5}),
WithMetricTypes([]string{"L2", "COSINE"}),
WithNormalize(false))
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
// ID 1 should have the highest score because:
// - L2 distance 0.1 converts to ~0.94 (close to 1.0, good match)
// - COSINE 0.95 stays as-is
// ID 2: L2 2.0 converts to ~0.30 (bad match), no COSINE contribution
// ID 3: no L2 contribution, COSINE 0.80
idCol := result.Column(types.IDFieldName)
scoreCol := result.Column(types.ScoreFieldName)
s.Require().NotNil(idCol)
s.Require().NotNil(scoreCol)
s.Equal(3, idCol.Chunk(0).Len())
// Results are sorted descending, so first result should be ID 1 (highest combined score)
idChunk := idCol.Chunk(0).(*array.Int64)
scoreChunk := scoreCol.Chunk(0).(*array.Float32)
s.Equal(int64(1), idChunk.Value(0))
s.True(scoreChunk.Value(0) > scoreChunk.Value(1), "scores should be descending")
s.True(scoreChunk.Value(1) > scoreChunk.Value(2), "scores should be descending")
}
func (s *MergeOpTestSuite) TestMergeOpWeighted_AllL2_NoNormalize() {
// Both inputs are L2: smaller = better
df1 := s.createDataFrame(
[]int64{1, 2},
[]float32{0.1, 0.5},
[]int64{2},
)
defer df1.Release()
df2 := s.createDataFrame(
[]int64{1, 2},
[]float32{0.2, 0.3},
[]int64{2},
)
defer df2.Release()
op := NewMergeOp(MergeStrategyWeighted,
WithWeights([]float64{0.5, 0.5}),
WithMetricTypes([]string{"L2", "L2"}),
WithNormalize(false))
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
// Not mixed, no conversion. Raw scores: ID1 = 0.5*(0.1+0.2) = 0.15, ID2 = 0.5*(0.5+0.3) = 0.4
// For all-L2 no-normalize, sort is ascending (smaller = better): ID1 (0.15) < ID2 (0.4)
idCol := result.Column(types.IDFieldName)
scoreCol := result.Column(types.ScoreFieldName)
s.Equal(2, idCol.Chunk(0).Len())
// Raw L2 sums, ascending order (smaller distance = better match)
scoreChunk := scoreCol.Chunk(0).(*array.Float32)
s.InDelta(0.15, float64(scoreChunk.Value(0)), 0.01)
s.InDelta(0.4, float64(scoreChunk.Value(1)), 0.01)
}
func (s *MergeOpTestSuite) TestMergeOpMax_MixedMetrics_NoNormalize() {
// L2 (smaller=better) and COSINE (larger=better)
df1 := s.createDataFrame(
[]int64{1},
[]float32{0.1}, // L2 distance 0.1 → converts to ~0.94
[]int64{1},
)
defer df1.Release()
df2 := s.createDataFrame(
[]int64{1},
[]float32{0.80}, // COSINE score
[]int64{1},
)
defer df2.Release()
op := NewMergeOp(MergeStrategyMax,
WithMetricTypes([]string{"L2", "COSINE"}),
WithNormalize(false))
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
// After direction conversion: L2 0.1 → ~0.94, COSINE 0.80 stays
// Max(0.94, 0.80) ≈ 0.94
scoreCol := result.Column(types.ScoreFieldName)
scoreChunk := scoreCol.Chunk(0).(*array.Float32)
s.InDelta(0.94, float64(scoreChunk.Value(0)), 0.01)
}
func (s *MergeOpTestSuite) TestClassifyMetricsOrder() {
mixed, desc := classifyMetricsOrder([]string{"COSINE", "L2"})
s.True(mixed)
s.True(desc)
mixed, desc = classifyMetricsOrder([]string{"COSINE", "IP"})
s.False(mixed)
s.True(desc)
mixed, desc = classifyMetricsOrder([]string{"L2", "L2"})
s.False(mixed)
s.False(desc)
mixed, desc = classifyMetricsOrder([]string{"COSINE", "IP", "BM25"})
s.False(mixed)
s.True(desc)
mixed, desc = classifyMetricsOrder([]string{"COSINE", "L2", "BM25"})
s.True(mixed)
s.True(desc)
}
func (s *MergeOpTestSuite) TestSortDescending() {
// normalize=true → always descending
op := NewMergeOp(MergeStrategyWeighted,
WithMetricTypes([]string{"L2", "L2"}),
WithNormalize(true))
s.True(op.SortDescending())
// normalize=false, all COSINE → descending
op = NewMergeOp(MergeStrategyWeighted,
WithMetricTypes([]string{"COSINE", "IP"}),
WithNormalize(false))
s.True(op.SortDescending())
// normalize=false, all L2 → ascending
op = NewMergeOp(MergeStrategyWeighted,
WithMetricTypes([]string{"L2", "L2"}),
WithNormalize(false))
s.False(op.SortDescending())
// normalize=false, mixed → descending (because direction conversion makes all larger-is-better)
op = NewMergeOp(MergeStrategyWeighted,
WithMetricTypes([]string{"L2", "COSINE"}),
WithNormalize(false))
s.True(op.SortDescending())
// no metric types → descending
op = NewMergeOp(MergeStrategyWeighted,
WithNormalize(false))
s.True(op.SortDescending())
}
// =============================================================================
// Additional MergeOp Tests: strategies, edge cases, Execute delegation
// =============================================================================
func (s *MergeOpTestSuite) TestMergeOpExecuteDelegatesToExecuteMulti() {
df := s.createDataFrame([]int64{1, 2}, []float32{0.9, 0.8}, []int64{2})
defer df.Release()
op := NewMergeOp(MergeStrategyMax, WithNormalize(false))
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
result, err := op.Execute(ctx, df)
s.Require().NoError(err)
defer result.Release()
s.Equal(int64(2), result.NumRows())
s.True(result.HasColumn(types.IDFieldName))
s.True(result.HasColumn(types.ScoreFieldName))
}
func (s *MergeOpTestSuite) TestMergeOpSum() {
df1 := s.createDataFrame([]int64{1, 2}, []float32{0.8, 0.3}, []int64{2})
defer df1.Release()
df2 := s.createDataFrame([]int64{1, 2}, []float32{0.5, 0.9}, []int64{2})
defer df2.Release()
op := NewMergeOp(MergeStrategySum,
WithMetricTypes([]string{"IP", "IP"}),
WithNormalize(false))
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
idCol := result.Column(types.IDFieldName)
scoreCol := result.Column(types.ScoreFieldName)
idChunk := idCol.Chunk(0).(*array.Int64)
scoreChunk := scoreCol.Chunk(0).(*array.Float32)
// ID 1: sum=1.3, ID 2: sum=1.2 → ID 1 first (descending)
s.Equal(int64(1), idChunk.Value(0))
s.InDelta(1.3, float64(scoreChunk.Value(0)), 1e-5)
s.Equal(int64(2), idChunk.Value(1))
s.InDelta(1.2, float64(scoreChunk.Value(1)), 1e-5)
}
func (s *MergeOpTestSuite) TestMergeOpAvg() {
df1 := s.createDataFrame([]int64{1, 2}, []float32{0.8, 0.4}, []int64{2})
defer df1.Release()
df2 := s.createDataFrame([]int64{1, 2}, []float32{0.6, 0.8}, []int64{2})
defer df2.Release()
op := NewMergeOp(MergeStrategyAvg,
WithMetricTypes([]string{"IP", "IP"}),
WithNormalize(false))
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
idCol := result.Column(types.IDFieldName)
scoreCol := result.Column(types.ScoreFieldName)
idChunk := idCol.Chunk(0).(*array.Int64)
scoreChunk := scoreCol.Chunk(0).(*array.Float32)
// ID 1: avg=(0.8+0.6)/2=0.7, ID 2: avg=(0.4+0.8)/2=0.6 → ID 1 first
s.Equal(int64(1), idChunk.Value(0))
s.InDelta(0.7, float64(scoreChunk.Value(0)), 1e-5)
s.Equal(int64(2), idChunk.Value(1))
s.InDelta(0.6, float64(scoreChunk.Value(1)), 1e-5)
}
func (s *MergeOpTestSuite) TestMergeOpUnsupportedStrategy() {
df1 := s.createDataFrame([]int64{1}, []float32{0.5}, []int64{1})
defer df1.Release()
df2 := s.createDataFrame([]int64{2}, []float32{0.5}, []int64{1})
defer df2.Release()
op := NewMergeOp(MergeStrategy("unknown"),
WithMetricTypes([]string{"IP", "IP"}))
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
_, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Error(err)
s.Contains(err.Error(), "unsupported strategy")
}
func (s *MergeOpTestSuite) TestMergeOpMetricTypeMismatch() {
df1 := s.createDataFrame([]int64{1}, []float32{0.5}, []int64{1})
defer df1.Release()
df2 := s.createDataFrame([]int64{2}, []float32{0.5}, []int64{1})
defer df2.Release()
// 3 metric types for 2 inputs
op := NewMergeOp(MergeStrategyRRF,
WithMetricTypes([]string{"IP", "IP", "L2"}))
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
_, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Error(err)
s.Contains(err.Error(), "scoreNormFuncs count")
}
func (s *MergeOpTestSuite) TestMergeOpChunkMismatch() {
df1 := s.createDataFrame([]int64{1, 2}, []float32{0.9, 0.8}, []int64{2})
defer df1.Release()
// Different number of chunks
df2 := s.createDataFrame([]int64{3, 4, 5, 6}, []float32{0.7, 0.6, 0.5, 0.4}, []int64{2, 2})
defer df2.Release()
op := NewMergeOp(MergeStrategyRRF,
WithMetricTypes([]string{"IP", "IP"}))
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
_, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Error(err)
s.Contains(err.Error(), "chunks")
}
func (s *MergeOpTestSuite) TestMergeOpStringIDs() {
resultData1 := &schemapb.SearchResultData{
NumQueries: 1, TopK: 2, Topks: []int64{2},
Scores: []float32{0.9, 0.8},
Ids: &schemapb.IDs{IdField: &schemapb.IDs_StrId{
StrId: &schemapb.StringArray{Data: []string{"a", "b"}},
}},
FieldsData: []*schemapb.FieldData{},
}
df1, err := FromSearchResultData(resultData1, s.pool, nil)
s.Require().NoError(err)
defer df1.Release()
resultData2 := &schemapb.SearchResultData{
NumQueries: 1, TopK: 2, Topks: []int64{2},
Scores: []float32{0.95, 0.85},
Ids: &schemapb.IDs{IdField: &schemapb.IDs_StrId{
StrId: &schemapb.StringArray{Data: []string{"b", "c"}},
}},
FieldsData: []*schemapb.FieldData{},
}
df2, err := FromSearchResultData(resultData2, s.pool, nil)
s.Require().NoError(err)
defer df2.Release()
op := NewMergeOp(MergeStrategyRRF,
WithRRFK(60),
WithMetricTypes([]string{"IP", "IP"}))
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
// 3 unique string IDs: a, b, c
s.Equal(int64(3), result.NumRows())
// "b" appears in both → highest RRF score → should be first
idCol := result.Column(types.IDFieldName)
idChunk := idCol.Chunk(0).(*array.String)
s.Equal("b", idChunk.Value(0))
}
func (s *MergeOpTestSuite) TestMergeOpFieldDataPropagated() {
// DataFrame with an extra "category" field
resultData1 := &schemapb.SearchResultData{
NumQueries: 1, TopK: 2, Topks: []int64{2},
Scores: []float32{0.9, 0.8},
Ids: &schemapb.IDs{IdField: &schemapb.IDs_IntId{
IntId: &schemapb.LongArray{Data: []int64{1, 2}},
}},
FieldsData: []*schemapb.FieldData{
{
FieldName: "category", FieldId: 100, Type: schemapb.DataType_Int64,
Field: &schemapb.FieldData_Scalars{Scalars: &schemapb.ScalarField{
Data: &schemapb.ScalarField_LongData{LongData: &schemapb.LongArray{Data: []int64{10, 20}}},
}},
},
},
}
df1, err := FromSearchResultData(resultData1, s.pool, []string{"category"})
s.Require().NoError(err)
defer df1.Release()
resultData2 := &schemapb.SearchResultData{
NumQueries: 1, TopK: 2, Topks: []int64{2},
Scores: []float32{0.95, 0.85},
Ids: &schemapb.IDs{IdField: &schemapb.IDs_IntId{
IntId: &schemapb.LongArray{Data: []int64{2, 3}},
}},
FieldsData: []*schemapb.FieldData{
{
FieldName: "category", FieldId: 100, Type: schemapb.DataType_Int64,
Field: &schemapb.FieldData_Scalars{Scalars: &schemapb.ScalarField{
Data: &schemapb.ScalarField_LongData{LongData: &schemapb.LongArray{Data: []int64{20, 30}}},
}},
},
},
}
df2, err := FromSearchResultData(resultData2, s.pool, []string{"category"})
s.Require().NoError(err)
defer df2.Release()
op := NewMergeOp(MergeStrategyRRF,
WithRRFK(60),
WithMetricTypes([]string{"IP", "IP"}))
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
// Should have 3 columns: $id, $score, category
s.Equal(3, result.NumColumns())
s.True(result.HasColumn("category"))
// Category column should have the same number of rows as result
catCol := result.Column("category")
s.Require().NotNil(catCol)
total := 0
for _, chunk := range catCol.Chunks() {
total += chunk.Len()
}
s.Equal(int(result.NumRows()), total)
}
func (s *MergeOpTestSuite) TestMergeOpEmptyDataFrames() {
df1 := s.createDataFrame([]int64{}, []float32{}, []int64{0})
defer df1.Release()
df2 := s.createDataFrame([]int64{}, []float32{}, []int64{0})
defer df2.Release()
op := NewMergeOp(MergeStrategyRRF,
WithMetricTypes([]string{"IP", "IP"}))
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
s.Equal(int64(0), result.NumRows())
}
func (s *MergeOpTestSuite) TestMergeOpStringIDsTieBreaking() {
// Create 2 DataFrames with String IDs where IDs appear at same rank → identical RRF scores
// df1: rank1="z_id"(score=0.9), rank2="a_id"(score=0.8)
// df2: rank1="m_id"(score=0.95), rank2="b_id"(score=0.85)
resultData1 := &schemapb.SearchResultData{
NumQueries: 1, TopK: 2, Topks: []int64{2},
Scores: []float32{0.9, 0.8},
Ids: &schemapb.IDs{IdField: &schemapb.IDs_StrId{
StrId: &schemapb.StringArray{Data: []string{"z_id", "a_id"}},
}},
FieldsData: []*schemapb.FieldData{},
}
df1, err := FromSearchResultData(resultData1, s.pool, nil)
s.Require().NoError(err)
defer df1.Release()
resultData2 := &schemapb.SearchResultData{
NumQueries: 1, TopK: 2, Topks: []int64{2},
Scores: []float32{0.95, 0.85},
Ids: &schemapb.IDs{IdField: &schemapb.IDs_StrId{
StrId: &schemapb.StringArray{Data: []string{"m_id", "b_id"}},
}},
FieldsData: []*schemapb.FieldData{},
}
df2, err := FromSearchResultData(resultData2, s.pool, nil)
s.Require().NoError(err)
defer df2.Release()
op := NewMergeOp(MergeStrategyRRF,
WithRRFK(60),
WithMetricTypes([]string{"IP", "IP"}))
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
result, err := op.ExecuteMulti(ctx, []*DataFrame{df1, df2})
s.Require().NoError(err)
defer result.Release()
// 4 unique string IDs, all different → no overlap → RRF tie at same rank
s.Equal(int64(4), result.NumRows())
// Verify tie-break by lexicographic ID order
// rank1 IDs: z_id and m_id both get RRF score 1/(60+1); tie-break: m_id < z_id
// rank2 IDs: a_id and b_id both get RRF score 1/(60+2); tie-break: a_id < b_id
idCol := result.Column(types.IDFieldName)
idChunk := idCol.Chunk(0).(*array.String)
s.Equal("m_id", idChunk.Value(0))
s.Equal("z_id", idChunk.Value(1))
s.Equal("a_id", idChunk.Value(2))
s.Equal("b_id", idChunk.Value(3))
}
func (s *MergeOpTestSuite) TestMergeOpString() {
op := NewMergeOp(MergeStrategyRRF)
s.Contains(op.String(), "rrf")
op = NewMergeOp(MergeStrategyWeighted)
s.Contains(op.String(), "weighted")
op = NewMergeOp(MergeStrategyMax)
s.Contains(op.String(), "max")
}
func (s *MergeOpTestSuite) TestMergeOpNormalizeIP() {
// Single input weighted merge with IP normalization
resultData := &schemapb.SearchResultData{
NumQueries: 1, TopK: 3, Topks: []int64{3},
Scores: []float32{10.0, 0.0, -5.0},
Ids: &schemapb.IDs{IdField: &schemapb.IDs_IntId{
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3}},
}},
FieldsData: []*schemapb.FieldData{},
}
df, err := FromSearchResultData(resultData, s.pool, nil)
s.Require().NoError(err)
defer df.Release()
op := NewMergeOp(MergeStrategyWeighted,
WithWeights([]float64{1.0}),
WithNormalize(true),
WithMetricTypes([]string{"IP"}))
fctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
result, err := op.ExecuteMulti(fctx, []*DataFrame{df})
s.Require().NoError(err)
defer result.Release()
// IP normalize: 0.5 + atan(score)/π
scoreCol := result.Column(types.ScoreFieldName)
scoreChunk := scoreCol.Chunk(0).(*array.Float32)
expected0 := float32(0.5 + math.Atan(10.0)/math.Pi)
expected1 := float32(0.5 + math.Atan(0.0)/math.Pi)
expected2 := float32(0.5 + math.Atan(-5.0)/math.Pi)
s.InDelta(float64(expected0), float64(scoreChunk.Value(0)), 1e-5)
s.InDelta(float64(expected1), float64(scoreChunk.Value(1)), 1e-5)
s.InDelta(float64(expected2), float64(scoreChunk.Value(2)), 1e-5)
}
func (s *MergeOpTestSuite) TestMergeOpNormalizeBM25() {
// Single input weighted merge with BM25 normalization
resultData := &schemapb.SearchResultData{
NumQueries: 1, TopK: 2, Topks: []int64{2},
Scores: []float32{5.0, 1.0},
Ids: &schemapb.IDs{IdField: &schemapb.IDs_IntId{
IntId: &schemapb.LongArray{Data: []int64{1, 2}},
}},
FieldsData: []*schemapb.FieldData{},
}
df, err := FromSearchResultData(resultData, s.pool, nil)
s.Require().NoError(err)
defer df.Release()
op := NewMergeOp(MergeStrategyWeighted,
WithWeights([]float64{1.0}),
WithNormalize(true),
WithMetricTypes([]string{"BM25"}))
fctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
result, err := op.ExecuteMulti(fctx, []*DataFrame{df})
s.Require().NoError(err)
defer result.Release()
// BM25 normalize: 2 * atan(score) / π
scoreCol := result.Column(types.ScoreFieldName)
scoreChunk := scoreCol.Chunk(0).(*array.Float32)
expected0 := float32(2 * math.Atan(5.0) / math.Pi)
expected1 := float32(2 * math.Atan(1.0) / math.Pi)
s.InDelta(float64(expected0), float64(scoreChunk.Value(0)), 1e-5)
s.InDelta(float64(expected1), float64(scoreChunk.Value(1)), 1e-5)
}