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

1125 lines
36 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 (
"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"
)
// =============================================================================
// GroupByOp Test Suite
// =============================================================================
type GroupByOpTestSuite struct {
suite.Suite
pool *memory.CheckedAllocator
}
func (s *GroupByOpTestSuite) SetupTest() {
s.pool = memory.NewCheckedAllocator(memory.NewGoAllocator())
}
func (s *GroupByOpTestSuite) TearDownTest() {
s.pool.AssertSize(s.T(), 0)
}
func TestGroupByOpTestSuite(t *testing.T) {
suite.Run(t, new(GroupByOpTestSuite))
}
// =============================================================================
// Helper Functions
// =============================================================================
// createGroupByTestDataFrame creates a test DataFrame for GroupBy tests.
// Chunk 0 (query 1): 8 rows
//
// $id: [1, 2, 3, 4, 5, 6, 7, 8]
// $score: [0.9, 0.8, 0.7, 0.6, 0.5, 0.85, 0.75, 0.65]
// category: ["A", "A", "A", "B", "B", "B", "C", "C"]
//
// Group A: scores 0.9, 0.8, 0.7 (max=0.9)
// Group B: scores 0.6, 0.5, 0.85 (max=0.85)
// Group C: scores 0.75, 0.65 (max=0.75)
func (s *GroupByOpTestSuite) createGroupByTestDataFrame() *DataFrame {
builder := NewDataFrameBuilder()
// Set chunk sizes
builder.SetChunkSizes([]int64{8})
// Build $id column
idBuilder := array.NewInt64Builder(s.pool)
idBuilder.AppendValues([]int64{1, 2, 3, 4, 5, 6, 7, 8}, nil)
idChunk := idBuilder.NewArray()
idBuilder.Release()
err := builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idChunk})
s.Require().NoError(err)
// Build $score column
scoreBuilder := array.NewFloat32Builder(s.pool)
scoreBuilder.AppendValues([]float32{0.9, 0.8, 0.7, 0.6, 0.5, 0.85, 0.75, 0.65}, nil)
scoreChunk := scoreBuilder.NewArray()
scoreBuilder.Release()
err = builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreChunk})
s.Require().NoError(err)
// Build category column
catBuilder := array.NewStringBuilder(s.pool)
catBuilder.AppendValues([]string{"A", "A", "A", "B", "B", "B", "C", "C"}, nil)
catChunk := catBuilder.NewArray()
catBuilder.Release()
err = builder.AddColumnFromChunks("category", []arrow.Array{catChunk})
s.Require().NoError(err)
return builder.Build()
}
// createMultiChunkGroupByTestDataFrame creates a DataFrame with multiple chunks.
func (s *GroupByOpTestSuite) createMultiChunkGroupByTestDataFrame() *DataFrame {
builder := NewDataFrameBuilder()
// Set chunk sizes: 2 chunks
builder.SetChunkSizes([]int64{5, 4})
// Chunk 0
idBuilder1 := array.NewInt64Builder(s.pool)
idBuilder1.AppendValues([]int64{1, 2, 3, 4, 5}, nil)
idChunk1 := idBuilder1.NewArray()
idBuilder1.Release()
scoreBuilder1 := array.NewFloat32Builder(s.pool)
scoreBuilder1.AppendValues([]float32{0.9, 0.8, 0.7, 0.6, 0.5}, nil)
scoreChunk1 := scoreBuilder1.NewArray()
scoreBuilder1.Release()
catBuilder1 := array.NewStringBuilder(s.pool)
catBuilder1.AppendValues([]string{"A", "A", "B", "B", "A"}, nil)
catChunk1 := catBuilder1.NewArray()
catBuilder1.Release()
// Chunk 1
idBuilder2 := array.NewInt64Builder(s.pool)
idBuilder2.AppendValues([]int64{6, 7, 8, 9}, nil)
idChunk2 := idBuilder2.NewArray()
idBuilder2.Release()
scoreBuilder2 := array.NewFloat32Builder(s.pool)
scoreBuilder2.AppendValues([]float32{0.85, 0.75, 0.65, 0.55}, nil)
scoreChunk2 := scoreBuilder2.NewArray()
scoreBuilder2.Release()
catBuilder2 := array.NewStringBuilder(s.pool)
catBuilder2.AppendValues([]string{"C", "C", "D", "C"}, nil)
catChunk2 := catBuilder2.NewArray()
catBuilder2.Release()
err := builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idChunk1, idChunk2})
s.Require().NoError(err)
err = builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreChunk1, scoreChunk2})
s.Require().NoError(err)
err = builder.AddColumnFromChunks("category", []arrow.Array{catChunk1, catChunk2})
s.Require().NoError(err)
return builder.Build()
}
// getChunkInt64Values extracts int64 values from a specific chunk.
func (s *GroupByOpTestSuite) getChunkInt64Values(col *arrow.Chunked, chunkIdx int) []int64 {
arr := col.Chunk(chunkIdx).(*array.Int64)
result := make([]int64, arr.Len())
for i := 0; i < arr.Len(); i++ {
result[i] = arr.Value(i)
}
return result
}
// getChunkFloat32Values extracts float32 values from a specific chunk.
func (s *GroupByOpTestSuite) getChunkFloat32Values(col *arrow.Chunked, chunkIdx int) []float32 {
arr := col.Chunk(chunkIdx).(*array.Float32)
result := make([]float32, arr.Len())
for i := 0; i < arr.Len(); i++ {
result[i] = arr.Value(i)
}
return result
}
// getChunkStringValues extracts string values from a specific chunk.
func (s *GroupByOpTestSuite) getChunkStringValues(col *arrow.Chunked, chunkIdx int) []string {
arr := col.Chunk(chunkIdx).(*array.String)
result := make([]string, arr.Len())
for i := 0; i < arr.Len(); i++ {
result[i] = arr.Value(i)
}
return result
}
// =============================================================================
// GroupByOp Basic Tests
// =============================================================================
func (s *GroupByOpTestSuite) TestGroupByOp_Name() {
op := NewGroupByOp("category", 2, 10, 0)
s.Equal("GroupBy", op.Name())
}
func (s *GroupByOpTestSuite) TestGroupByOp_String() {
op := NewGroupByOp("category", 2, 10, 5)
str := op.String()
s.Contains(str, "GroupBy")
s.Contains(str, "category")
s.Contains(str, "groupSize=2")
s.Contains(str, "limit=10")
s.Contains(str, "offset=5")
}
// =============================================================================
// GroupByOp Execute Tests
// =============================================================================
// TestGroupByOp_AscDirection verifies that SetSortDescending(false) flips
// both within-group trimming and cross-group ordering to ASC semantics.
//
// Reuses the standard 8-row fixture (ids 1..8 with descending scores split
// across categories A/B/C). With sortDescending=false:
//
// - Within-group trim must keep the *smallest* groupSize rows per group:
// A → keeps ids [3 (0.7), 2 (0.8)], drops id 1 (0.9)
// B → keeps ids [5 (0.5), 4 (0.6)], drops id 6 (0.85)
// C → keeps ids [8 (0.65), 7 (0.75)] (only 2 rows in this group)
//
// - Group score (Max scorer) is scores[0] after ASC sort = the smallest:
// A → 0.7, B → 0.5, C → 0.65
//
// - Groups are ordered ASC by group score:
// B (0.5) → C (0.65) → A (0.7)
//
// Expected flat output: ids [5, 4, 8, 7, 3, 2], scores [0.5, 0.6, 0.65, 0.75, 0.7, 0.8]
//
// Compare to TestGroupByOp_Basic which uses the same fixture with DESC mode
// and produces a completely different result.
func (s *GroupByOpTestSuite) TestGroupByOp_AscDirection() {
df := s.createGroupByTestDataFrame()
defer df.Release()
op := NewGroupByOpWithScorer("category", 2, 3, 0, GroupScorerMax).
SetSortDescending(false)
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
Add(op).
Execute(df)
s.Require().NoError(err)
defer result.Release()
s.Equal(int64(6), result.NumRows())
ids := s.getChunkInt64Values(result.Column(types.IDFieldName), 0)
scores := s.getChunkFloat32Values(result.Column(types.ScoreFieldName), 0)
cats := s.getChunkStringValues(result.Column("category"), 0)
groupScores := s.getChunkFloat32Values(result.Column(GroupScoreFieldName), 0)
s.Equal([]int64{5, 4, 8, 7, 3, 2}, ids,
"ASC mode must keep smallest groupSize rows per group and order groups ASC by group score; got %v with scores %v cats %v",
ids, scores, cats)
// Group ordering: B → C → A
s.Equal([]string{"B", "B", "C", "C", "A", "A"}, cats)
// Group scores must be ASC and equal to the smallest score in each group
s.InDelta(float32(0.5), groupScores[0], 1e-6, "group B representative")
s.InDelta(float32(0.5), groupScores[1], 1e-6)
s.InDelta(float32(0.65), groupScores[2], 1e-6, "group C representative")
s.InDelta(float32(0.65), groupScores[3], 1e-6)
s.InDelta(float32(0.7), groupScores[4], 1e-6, "group A representative")
s.InDelta(float32(0.7), groupScores[5], 1e-6)
// Within-group rows must be ASC by score (best first under ASC semantics)
s.InDelta(float32(0.5), scores[0], 1e-6)
s.InDelta(float32(0.6), scores[1], 1e-6)
s.InDelta(float32(0.65), scores[2], 1e-6)
s.InDelta(float32(0.75), scores[3], 1e-6)
s.InDelta(float32(0.7), scores[4], 1e-6)
s.InDelta(float32(0.8), scores[5], 1e-6)
}
// TestGroupByOp_DefaultSortDescending verifies that the default constructor
// produces DESC behavior (legacy contract preserved).
func (s *GroupByOpTestSuite) TestGroupByOp_DefaultSortDescending() {
op := NewGroupByOp("category", 2, 10, 0)
s.True(op.sortDescending, "default sort direction must be descending to preserve legacy behavior")
op2 := NewGroupByOpWithScorer("category", 2, 10, 0, GroupScorerSum)
s.True(op2.sortDescending, "NewGroupByOpWithScorer must default to descending")
op3 := NewGroupByOp("category", 2, 10, 0).SetSortDescending(false)
s.False(op3.sortDescending, "SetSortDescending(false) must flip the flag")
op4 := op3.SetSortDescending(true)
s.True(op4.sortDescending, "SetSortDescending must be reversible")
s.Same(op3, op4, "SetSortDescending must return the receiver for chaining")
}
func (s *GroupByOpTestSuite) TestGroupByOp_Basic() {
df := s.createGroupByTestDataFrame()
defer df.Release()
// GroupBy category, groupSize=2, limit=3, offset=0
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupBy("category", 2, 3, 0).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Should have $group_score column
s.True(result.HasColumn(GroupScoreFieldName))
// Groups sorted by max score:
// A: max=0.9, top 2 rows: ids 1(0.9), 2(0.8)
// B: max=0.85, top 2 rows: ids 6(0.85), 4(0.6)
// C: max=0.75, top 2 rows: ids 7(0.75), 8(0.65)
// Output order: A, A, B, B, C, C (6 rows total)
s.Equal(int64(6), result.NumRows())
categories := s.getChunkStringValues(result.Column("category"), 0)
groupScores := s.getChunkFloat32Values(result.Column(GroupScoreFieldName), 0)
// First 2 rows should be group A
s.Equal("A", categories[0])
s.Equal("A", categories[1])
s.InDelta(float32(0.9), groupScores[0], 0.001)
s.InDelta(float32(0.9), groupScores[1], 0.001)
// Next 2 rows should be group B
s.Equal("B", categories[2])
s.Equal("B", categories[3])
s.InDelta(float32(0.85), groupScores[2], 0.001)
s.InDelta(float32(0.85), groupScores[3], 0.001)
// Last 2 rows should be group C
s.Equal("C", categories[4])
s.Equal("C", categories[5])
s.InDelta(float32(0.75), groupScores[4], 0.001)
s.InDelta(float32(0.75), groupScores[5], 0.001)
}
func (s *GroupByOpTestSuite) TestGroupByOp_GroupSizeLimit() {
df := s.createGroupByTestDataFrame()
defer df.Release()
// GroupBy category, groupSize=1 (only top 1 per group)
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupBy("category", 1, 3, 0).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Only 3 rows (1 per group)
s.Equal(int64(3), result.NumRows())
categories := s.getChunkStringValues(result.Column("category"), 0)
scores := s.getChunkFloat32Values(result.Column(types.ScoreFieldName), 0)
// Each group's top scorer
s.Equal("A", categories[0])
s.InDelta(float32(0.9), scores[0], 0.001) // Top of A
s.Equal("B", categories[1])
s.InDelta(float32(0.85), scores[1], 0.001) // Top of B
s.Equal("C", categories[2])
s.InDelta(float32(0.75), scores[2], 0.001) // Top of C
}
func (s *GroupByOpTestSuite) TestGroupByOp_LimitGroups() {
df := s.createGroupByTestDataFrame()
defer df.Release()
// GroupBy category, groupSize=2, limit=2 (only top 2 groups)
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupBy("category", 2, 2, 0).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Only 4 rows (2 groups * 2 rows)
s.Equal(int64(4), result.NumRows())
categories := s.getChunkStringValues(result.Column("category"), 0)
// Should only have A and B (top 2 groups by score)
distinctCats := make(map[string]bool)
for _, cat := range categories {
distinctCats[cat] = true
}
s.Equal(2, len(distinctCats))
s.True(distinctCats["A"])
s.True(distinctCats["B"])
s.False(distinctCats["C"]) // C should be excluded
}
func (s *GroupByOpTestSuite) TestGroupByOp_Offset() {
df := s.createGroupByTestDataFrame()
defer df.Release()
// GroupBy category, groupSize=2, limit=2, offset=1 (skip first group)
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupBy("category", 2, 2, 1).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Only 4 rows (2 groups * 2 rows), skipped group A
s.Equal(int64(4), result.NumRows())
categories := s.getChunkStringValues(result.Column("category"), 0)
// Should have B and C (skipped A)
distinctCats := make(map[string]bool)
for _, cat := range categories {
distinctCats[cat] = true
}
s.Equal(2, len(distinctCats))
s.False(distinctCats["A"]) // A should be skipped
s.True(distinctCats["B"])
s.True(distinctCats["C"])
}
func (s *GroupByOpTestSuite) TestGroupByOp_OffsetExceedsGroups() {
df := s.createGroupByTestDataFrame()
defer df.Release()
// offset=10 exceeds number of groups (3)
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupBy("category", 2, 10, 10).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Should return empty result
s.Equal(int64(0), result.NumRows())
}
func (s *GroupByOpTestSuite) TestGroupByOp_MultipleChunks() {
df := s.createMultiChunkGroupByTestDataFrame()
defer df.Release()
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupBy("category", 2, 10, 0).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Each chunk is processed independently
s.Equal(2, result.NumChunks())
// Chunk 0: A(0.9,0.8), B(0.7,0.6) - A first (higher max)
chunk0Cats := s.getChunkStringValues(result.Column("category"), 0)
s.Equal(4, len(chunk0Cats)) // 2 groups * 2 rows
// Chunk 1: C(0.85,0.75), D(0.65) - C first (higher max)
chunk1Cats := s.getChunkStringValues(result.Column("category"), 1)
s.True(len(chunk1Cats) <= 4)
}
// =============================================================================
// GroupByOp Error Tests
// =============================================================================
func (s *GroupByOpTestSuite) TestGroupByOp_NonExistentGroupField() {
df := s.createGroupByTestDataFrame()
defer df.Release()
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
op := NewGroupByOp("nonexistent", 2, 10, 0)
_, err := op.Execute(ctx, df)
s.Error(err)
s.Contains(err.Error(), "not found")
}
func (s *GroupByOpTestSuite) TestGroupByOp_NoScoreColumn() {
// Create DataFrame without $score column
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{3})
idBuilder := array.NewInt64Builder(s.pool)
idBuilder.AppendValues([]int64{1, 2, 3}, nil)
idChunk := idBuilder.NewArray()
idBuilder.Release()
catBuilder := array.NewStringBuilder(s.pool)
catBuilder.AppendValues([]string{"A", "A", "B"}, nil)
catChunk := catBuilder.NewArray()
catBuilder.Release()
err := builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idChunk})
s.Require().NoError(err)
err = builder.AddColumnFromChunks("category", []arrow.Array{catChunk})
s.Require().NoError(err)
df := builder.Build()
defer df.Release()
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
op := NewGroupByOp("category", 2, 10, 0)
_, err = op.Execute(ctx, df)
s.Error(err)
s.Contains(err.Error(), types.ScoreFieldName)
}
// =============================================================================
// FuncChain GroupBy Method Tests
// =============================================================================
func (s *GroupByOpTestSuite) TestFuncChain_GroupBy() {
df := s.createGroupByTestDataFrame()
defer df.Release()
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupBy("category", 2, 3, 0).
Execute(df)
s.Require().NoError(err)
defer result.Release()
s.True(result.HasColumn(GroupScoreFieldName))
s.Equal(int64(6), result.NumRows())
}
func (s *GroupByOpTestSuite) TestFuncChain_GroupBy_InvalidParams() {
// Empty field
fc := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupBy("", 2, 10, 0)
err := fc.Validate()
s.Error(err)
s.Contains(err.Error(), "empty")
// Invalid groupSize
fc = NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupBy("category", 0, 10, 0)
err = fc.Validate()
s.Error(err)
s.Contains(err.Error(), "groupSize")
// Invalid limit
fc = NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupBy("category", 2, 0, 0)
err = fc.Validate()
s.Error(err)
s.Contains(err.Error(), "limit")
// Invalid offset
fc = NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupBy("category", 2, 10, -1)
err = fc.Validate()
s.Error(err)
s.Contains(err.Error(), "offset")
}
// =============================================================================
// NewGroupByOpFromRepr Tests
// =============================================================================
func (s *GroupByOpTestSuite) TestNewGroupByOpFromRepr() {
repr := &OperatorRepr{
Type: types.OpTypeGroupBy,
Params: map[string]*schemapb.FunctionParamValue{
"field": stringParam("category"),
"group_size": intParam(3),
"limit": intParam(10),
"offset": intParam(5),
},
}
op, err := NewGroupByOpFromRepr(repr)
s.Require().NoError(err)
s.NotNil(op)
groupByOp := op.(*GroupByOp)
s.Equal("category", groupByOp.groupByField)
s.Equal(int64(3), groupByOp.groupSize)
s.Equal(int64(10), groupByOp.limit)
s.Equal(int64(5), groupByOp.offset)
}
func (s *GroupByOpTestSuite) TestNewGroupByOpFromRepr_DefaultOffset() {
repr := &OperatorRepr{
Type: types.OpTypeGroupBy,
Params: map[string]*schemapb.FunctionParamValue{
"field": stringParam("category"),
"group_size": intParam(3),
"limit": intParam(10),
},
}
op, err := NewGroupByOpFromRepr(repr)
s.Require().NoError(err)
groupByOp := op.(*GroupByOp)
s.Equal(int64(0), groupByOp.offset) // Default offset is 0
}
// =============================================================================
// Memory Leak Tests
// =============================================================================
func (s *GroupByOpTestSuite) TestMemoryLeak_GroupByOp() {
df := s.createGroupByTestDataFrame()
defer df.Release()
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupBy("category", 2, 3, 0).
Execute(df)
s.Require().NoError(err)
result.Release()
// Memory check happens in TearDownTest
}
func (s *GroupByOpTestSuite) TestMemoryLeak_GroupByOp_Error() {
df := s.createGroupByTestDataFrame()
defer df.Release()
ctx := types.NewFuncContextWithStage(s.pool, types.StageL2Rerank)
op := NewGroupByOp("nonexistent", 2, 10, 0)
_, err := op.Execute(ctx, df)
s.Error(err)
// Memory check happens in TearDownTest
}
// =============================================================================
// Integration Tests
// =============================================================================
func (s *GroupByOpTestSuite) TestGroupByOp_WithSort() {
df := s.createGroupByTestDataFrame()
defer df.Release()
// GroupBy then Sort by $group_score (should already be sorted)
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupBy("category", 2, 3, 0).
Sort(GroupScoreFieldName, true, types.IDFieldName).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Verify still sorted by group score DESC
groupScores := s.getChunkFloat32Values(result.Column(GroupScoreFieldName), 0)
for i := 0; i < len(groupScores)-1; i++ {
s.GreaterOrEqual(groupScores[i], groupScores[i+1])
}
}
func (s *GroupByOpTestSuite) TestGroupByOp_WithLimit() {
df := s.createGroupByTestDataFrame()
defer df.Release()
// GroupBy then Limit (total rows)
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupBy("category", 2, 3, 0).
Limit(3).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Should have only 3 rows
s.Equal(int64(3), result.NumRows())
}
// =============================================================================
// GroupScorer Tests
// =============================================================================
func (s *GroupByOpTestSuite) TestGroupByOp_ScorerMax() {
df := s.createGroupByTestDataFrame()
defer df.Release()
// Use max scorer (default)
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupByWithScorer("category", 2, 3, 0, GroupScorerMax).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Group A: scores 0.9, 0.8 -> max = 0.9
// Group B: scores 0.85, 0.6 -> max = 0.85
// Group C: scores 0.75, 0.65 -> max = 0.75
groupScores := s.getChunkFloat32Values(result.Column(GroupScoreFieldName), 0)
// First 2 rows are group A with score 0.9
s.InDelta(float32(0.9), groupScores[0], 0.001)
s.InDelta(float32(0.9), groupScores[1], 0.001)
// Next 2 rows are group B with score 0.85
s.InDelta(float32(0.85), groupScores[2], 0.001)
s.InDelta(float32(0.85), groupScores[3], 0.001)
}
func (s *GroupByOpTestSuite) TestGroupByOp_ScorerSum() {
df := s.createGroupByTestDataFrame()
defer df.Release()
// Use sum scorer
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupByWithScorer("category", 2, 3, 0, GroupScorerSum).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Group A: top 2 scores 0.9, 0.8 -> sum = 1.7
// Group B: top 2 scores 0.85, 0.6 -> sum = 1.45
// Group C: top 2 scores 0.75, 0.65 -> sum = 1.4
// Order by sum DESC: A, B, C
groupScores := s.getChunkFloat32Values(result.Column(GroupScoreFieldName), 0)
categories := s.getChunkStringValues(result.Column("category"), 0)
// First 2 rows are group A with sum = 1.7
s.Equal("A", categories[0])
s.InDelta(float32(1.7), groupScores[0], 0.001)
s.InDelta(float32(1.7), groupScores[1], 0.001)
// Next 2 rows are group B with sum = 1.45
s.Equal("B", categories[2])
s.InDelta(float32(1.45), groupScores[2], 0.001)
// Last 2 rows are group C with sum = 1.4
s.Equal("C", categories[4])
s.InDelta(float32(1.4), groupScores[4], 0.001)
}
func (s *GroupByOpTestSuite) TestGroupByOp_ScorerAvg() {
df := s.createGroupByTestDataFrame()
defer df.Release()
// Use avg scorer
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupByWithScorer("category", 2, 3, 0, GroupScorerAvg).
Execute(df)
s.Require().NoError(err)
defer result.Release()
// Group A: top 2 scores 0.9, 0.8 -> avg = 0.85
// Group B: top 2 scores 0.85, 0.6 -> avg = 0.725
// Group C: top 2 scores 0.75, 0.65 -> avg = 0.7
// Order by avg DESC: A, B, C
groupScores := s.getChunkFloat32Values(result.Column(GroupScoreFieldName), 0)
categories := s.getChunkStringValues(result.Column("category"), 0)
// First 2 rows are group A with avg = 0.85
s.Equal("A", categories[0])
s.InDelta(float32(0.85), groupScores[0], 0.001)
// Next 2 rows are group B with avg = 0.725
s.Equal("B", categories[2])
s.InDelta(float32(0.725), groupScores[2], 0.001)
// Last 2 rows are group C with avg = 0.7
s.Equal("C", categories[4])
s.InDelta(float32(0.7), groupScores[4], 0.001)
}
func (s *GroupByOpTestSuite) TestGroupByOp_ScorerChangesGroupOrder() {
// Create DataFrame where different scorers produce different group orders
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{6})
idBuilder := array.NewInt64Builder(s.pool)
idBuilder.AppendValues([]int64{1, 2, 3, 4, 5, 6}, nil)
idChunk := idBuilder.NewArray()
idBuilder.Release()
// Group A: 0.9, 0.1 -> max=0.9, sum=1.0, avg=0.5
// Group B: 0.6, 0.5 -> max=0.6, sum=1.1, avg=0.55
scoreBuilder := array.NewFloat32Builder(s.pool)
scoreBuilder.AppendValues([]float32{0.9, 0.1, 0.6, 0.5, 0.3, 0.2}, nil)
scoreChunk := scoreBuilder.NewArray()
scoreBuilder.Release()
catBuilder := array.NewStringBuilder(s.pool)
catBuilder.AppendValues([]string{"A", "A", "B", "B", "C", "C"}, nil)
catChunk := catBuilder.NewArray()
catBuilder.Release()
builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idChunk})
builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreChunk})
builder.AddColumnFromChunks("category", []arrow.Array{catChunk})
df := builder.Build()
defer df.Release()
// With max scorer: A(0.9) > B(0.6) > C(0.3)
resultMax, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupByWithScorer("category", 2, 3, 0, GroupScorerMax).
Execute(df)
s.Require().NoError(err)
defer resultMax.Release()
catsMax := s.getChunkStringValues(resultMax.Column("category"), 0)
s.Equal("A", catsMax[0]) // A is first with max scorer
// With sum scorer: B(1.1) > A(1.0) > C(0.5)
resultSum, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupByWithScorer("category", 2, 3, 0, GroupScorerSum).
Execute(df)
s.Require().NoError(err)
defer resultSum.Release()
catsSum := s.getChunkStringValues(resultSum.Column("category"), 0)
s.Equal("B", catsSum[0]) // B is first with sum scorer
// With avg scorer: B(0.55) > A(0.5) > C(0.25)
resultAvg, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupByWithScorer("category", 2, 3, 0, GroupScorerAvg).
Execute(df)
s.Require().NoError(err)
defer resultAvg.Release()
catsAvg := s.getChunkStringValues(resultAvg.Column("category"), 0)
s.Equal("B", catsAvg[0]) // B is first with avg scorer
}
func (s *GroupByOpTestSuite) TestNewGroupByOpFromRepr_WithScorer() {
// Test max scorer
repr := &OperatorRepr{
Type: types.OpTypeGroupBy,
Params: map[string]*schemapb.FunctionParamValue{
"field": stringParam("category"),
"group_size": intParam(3),
"limit": intParam(10),
"scorer": stringParam("max"),
},
}
op, err := NewGroupByOpFromRepr(repr)
s.Require().NoError(err)
s.Equal(GroupScorerMax, op.(*GroupByOp).groupScorer)
// Test sum scorer
repr.Params["scorer"] = stringParam("sum")
op, err = NewGroupByOpFromRepr(repr)
s.Require().NoError(err)
s.Equal(GroupScorerSum, op.(*GroupByOp).groupScorer)
// Test avg scorer
repr.Params["scorer"] = stringParam("avg")
op, err = NewGroupByOpFromRepr(repr)
s.Require().NoError(err)
s.Equal(GroupScorerAvg, op.(*GroupByOp).groupScorer)
// Test invalid scorer
repr.Params["scorer"] = stringParam("invalid")
_, err = NewGroupByOpFromRepr(repr)
s.Error(err)
s.Contains(err.Error(), "invalid group scorer")
}
func (s *GroupByOpTestSuite) TestGroupByOp_String_WithScorer() {
op := NewGroupByOpWithScorer("category", 2, 10, 5, GroupScorerSum)
str := op.String()
s.Contains(str, "scorer=sum")
}
// =============================================================================
// Tiebreaking Tests
// =============================================================================
// TestGroupByOp_IntraGroupTiebreaking verifies that within a group,
// rows with equal scores are ordered by id ASC (smaller id first).
func (s *GroupByOpTestSuite) TestGroupByOp_IntraGroupTiebreaking() {
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{4})
// All rows in same group "A", same score 0.5, different ids
idBuilder := array.NewInt64Builder(s.pool)
idBuilder.AppendValues([]int64{30, 10, 40, 20}, nil)
idChunk := idBuilder.NewArray()
idBuilder.Release()
scoreBuilder := array.NewFloat32Builder(s.pool)
scoreBuilder.AppendValues([]float32{0.5, 0.5, 0.5, 0.5}, nil)
scoreChunk := scoreBuilder.NewArray()
scoreBuilder.Release()
catBuilder := array.NewStringBuilder(s.pool)
catBuilder.AppendValues([]string{"A", "A", "A", "A"}, nil)
catChunk := catBuilder.NewArray()
catBuilder.Release()
builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idChunk})
builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreChunk})
builder.AddColumnFromChunks("category", []arrow.Array{catChunk})
df := builder.Build()
defer df.Release()
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupByWithScorer("category", 4, 10, 0, GroupScorerMax).
Execute(df)
s.Require().NoError(err)
defer result.Release()
ids := s.getChunkInt64Values(result.Column(types.IDFieldName), 0)
// Equal scores should be ordered by id ASC: 10, 20, 30, 40
s.Equal([]int64{10, 20, 30, 40}, ids)
}
// TestGroupByOp_IntraGroupTiebreaking_StringID verifies tiebreaking with string PKs.
func (s *GroupByOpTestSuite) TestGroupByOp_IntraGroupTiebreaking_StringID() {
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{3})
idBuilder := array.NewStringBuilder(s.pool)
idBuilder.AppendValues([]string{"cherry", "apple", "banana"}, nil)
idChunk := idBuilder.NewArray()
idBuilder.Release()
scoreBuilder := array.NewFloat32Builder(s.pool)
scoreBuilder.AppendValues([]float32{0.5, 0.5, 0.5}, nil)
scoreChunk := scoreBuilder.NewArray()
scoreBuilder.Release()
catBuilder := array.NewStringBuilder(s.pool)
catBuilder.AppendValues([]string{"A", "A", "A"}, nil)
catChunk := catBuilder.NewArray()
catBuilder.Release()
builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idChunk})
builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreChunk})
builder.AddColumnFromChunks("category", []arrow.Array{catChunk})
df := builder.Build()
defer df.Release()
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupByWithScorer("category", 3, 10, 0, GroupScorerMax).
Execute(df)
s.Require().NoError(err)
defer result.Release()
ids := s.getChunkStringValues(result.Column(types.IDFieldName), 0)
// Equal scores should be ordered by string id ASC: "apple", "banana", "cherry"
s.Equal([]string{"apple", "banana", "cherry"}, ids)
}
// TestGroupByOp_InterGroupTiebreaking_LargerGroupFirst verifies that when two groups
// have the same groupScore, the group with more rows comes first.
func (s *GroupByOpTestSuite) TestGroupByOp_InterGroupTiebreaking_LargerGroupFirst() {
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{5})
// Group A: 2 rows, max score 0.8
// Group B: 3 rows, max score 0.8 (same score, but larger group)
idBuilder := array.NewInt64Builder(s.pool)
idBuilder.AppendValues([]int64{1, 2, 3, 4, 5}, nil)
idChunk := idBuilder.NewArray()
idBuilder.Release()
scoreBuilder := array.NewFloat32Builder(s.pool)
scoreBuilder.AppendValues([]float32{0.8, 0.7, 0.8, 0.6, 0.5}, nil)
scoreChunk := scoreBuilder.NewArray()
scoreBuilder.Release()
catBuilder := array.NewStringBuilder(s.pool)
catBuilder.AppendValues([]string{"A", "A", "B", "B", "B"}, nil)
catChunk := catBuilder.NewArray()
catBuilder.Release()
builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idChunk})
builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreChunk})
builder.AddColumnFromChunks("category", []arrow.Array{catChunk})
df := builder.Build()
defer df.Release()
// groupSize=3, so A keeps 2 rows, B keeps 3 rows
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupByWithScorer("category", 3, 10, 0, GroupScorerMax).
Execute(df)
s.Require().NoError(err)
defer result.Release()
categories := s.getChunkStringValues(result.Column("category"), 0)
// B has 3 rows, A has 2 rows, same max score -> B first
s.Equal("B", categories[0])
s.Equal("B", categories[1])
s.Equal("B", categories[2])
s.Equal("A", categories[3])
s.Equal("A", categories[4])
}
// TestGroupByOp_InterGroupTiebreaking_SmallerIDFirst verifies that when two groups
// have the same groupScore and same size, the group with smaller first id comes first.
func (s *GroupByOpTestSuite) TestGroupByOp_InterGroupTiebreaking_SmallerIDFirst() {
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{4})
// Group A: ids [20, 30], max score 0.8
// Group B: ids [5, 10], max score 0.8 (same score, same size, but smaller first id)
idBuilder := array.NewInt64Builder(s.pool)
idBuilder.AppendValues([]int64{20, 30, 5, 10}, nil)
idChunk := idBuilder.NewArray()
idBuilder.Release()
scoreBuilder := array.NewFloat32Builder(s.pool)
scoreBuilder.AppendValues([]float32{0.8, 0.7, 0.8, 0.6}, nil)
scoreChunk := scoreBuilder.NewArray()
scoreBuilder.Release()
catBuilder := array.NewStringBuilder(s.pool)
catBuilder.AppendValues([]string{"A", "A", "B", "B"}, nil)
catChunk := catBuilder.NewArray()
catBuilder.Release()
builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idChunk})
builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreChunk})
builder.AddColumnFromChunks("category", []arrow.Array{catChunk})
df := builder.Build()
defer df.Release()
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupByWithScorer("category", 2, 10, 0, GroupScorerMax).
Execute(df)
s.Require().NoError(err)
defer result.Release()
categories := s.getChunkStringValues(result.Column("category"), 0)
// Same score, same size -> B first (smaller first id: 5 < 20)
s.Equal("B", categories[0])
s.Equal("B", categories[1])
s.Equal("A", categories[2])
s.Equal("A", categories[3])
}
// TestGroupByOp_InterGroupTiebreaking_ScoreOverridesSize verifies that score
// takes precedence over group size in tiebreaking.
func (s *GroupByOpTestSuite) TestGroupByOp_InterGroupTiebreaking_ScoreOverridesSize() {
builder := NewDataFrameBuilder()
builder.SetChunkSizes([]int64{5})
// Group A: 3 rows, max score 0.7 (larger group but lower score)
// Group B: 1 row, max score 0.9 (smaller group but higher score)
idBuilder := array.NewInt64Builder(s.pool)
idBuilder.AppendValues([]int64{1, 2, 3, 4, 5}, nil)
idChunk := idBuilder.NewArray()
idBuilder.Release()
scoreBuilder := array.NewFloat32Builder(s.pool)
scoreBuilder.AppendValues([]float32{0.7, 0.6, 0.5, 0.9, 0.3}, nil)
scoreChunk := scoreBuilder.NewArray()
scoreBuilder.Release()
catBuilder := array.NewStringBuilder(s.pool)
catBuilder.AppendValues([]string{"A", "A", "A", "B", "C"}, nil)
catChunk := catBuilder.NewArray()
catBuilder.Release()
builder.AddColumnFromChunks(types.IDFieldName, []arrow.Array{idChunk})
builder.AddColumnFromChunks(types.ScoreFieldName, []arrow.Array{scoreChunk})
builder.AddColumnFromChunks("category", []arrow.Array{catChunk})
df := builder.Build()
defer df.Release()
result, err := NewFuncChainWithAllocator(s.pool).
SetStage(types.StageL2Rerank).
GroupByWithScorer("category", 3, 10, 0, GroupScorerMax).
Execute(df)
s.Require().NoError(err)
defer result.Release()
categories := s.getChunkStringValues(result.Column("category"), 0)
// Score takes precedence: B(0.9) > A(0.7) > C(0.3)
s.Equal("B", categories[0])
s.Equal("A", categories[1])
}
// =============================================================================
// compareValues Tests
// =============================================================================
func (s *GroupByOpTestSuite) TestCompareValues_Int64() {
s.Equal(-1, compareValues(int64(1), int64(2)))
s.Equal(0, compareValues(int64(5), int64(5)))
s.Equal(1, compareValues(int64(10), int64(3)))
}
func (s *GroupByOpTestSuite) TestCompareValues_String() {
s.Equal(-1, compareValues("apple", "banana"))
s.Equal(0, compareValues("hello", "hello"))
s.Equal(1, compareValues("zebra", "alpha"))
}
func (s *GroupByOpTestSuite) TestCompareValues_TypeMismatch() {
s.Equal(0, compareValues(int64(1), "string"))
s.Equal(0, compareValues("string", int64(1)))
}
func (s *GroupByOpTestSuite) TestCompareValues_UnsupportedType() {
s.Equal(0, compareValues(float64(1.0), float64(2.0)))
s.Equal(0, compareValues(nil, nil))
}