Some checks failed
Coverage / coverage (push) Failing after 6s
Tests / test (1.24.x, ubuntu-latest) (push) Failing after 42s
Tests / test (1.26.x, ubuntu-latest) (push) Failing after 38s
Tests / test (1.25.x, ubuntu-latest) (push) Failing after 35s
Tests / test (1.25.x, windows-latest) (push) Has been cancelled
Tests / test (1.26.x, macos-latest) (push) Has been cancelled
Tests / test (1.26.x, windows-latest) (push) Has been cancelled
Tests / test (1.24.x, macos-latest) (push) Has been cancelled
Tests / test (1.24.x, windows-latest) (push) Has been cancelled
Tests / test (1.25.x, macos-latest) (push) Has been cancelled
203 lines
6.0 KiB
Go
203 lines
6.0 KiB
Go
// Copyright (c) 2026 Couchbase, Inc.
|
|
//
|
|
// Licensed 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 collector
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/blevesearch/bleve/v2/search"
|
|
)
|
|
|
|
// scoreDesc is a collectorCompare for score-descending order.
|
|
// Returns positive when a.Score < b.Score (a is worse — lower score — and
|
|
// should sit closer to the root of the min-heap).
|
|
func scoreDesc(a, b *search.DocumentMatch) int {
|
|
if a.Score < b.Score {
|
|
return 1
|
|
}
|
|
if a.Score > b.Score {
|
|
return -1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func makeScoreDoc(score float64) *search.DocumentMatch {
|
|
return &search.DocumentMatch{Score: score}
|
|
}
|
|
|
|
// checkTernaryInvariant verifies the ternary min-heap property: for every node
|
|
// i the parent (at (i-1)/3) must be as bad or worse than the child, i.e.
|
|
// compare(parent, child) >= 0.
|
|
func checkTernaryInvariant(t *testing.T, h *collectStoreHeap) {
|
|
t.Helper()
|
|
for i := 1; i < len(h.heap); i++ {
|
|
parent := (i - 1) / 3
|
|
if h.compare(h.heap[parent], h.heap[i]) < 0 {
|
|
t.Errorf("ternary heap invariant violated at index %d (score=%.2f): parent %d (score=%.2f) is better, should be at least as bad",
|
|
i, h.heap[i].Score, parent, h.heap[parent].Score)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTernaryHeapInvariantAfterInserts inserts scores in several orderings and
|
|
// verifies the ternary invariant holds after every insertion.
|
|
func TestTernaryHeapInvariantAfterInserts(t *testing.T) {
|
|
for _, scores := range [][]float64{
|
|
{5, 3, 8, 1, 7, 2, 9, 4, 6, 10}, // unsorted
|
|
{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, // ascending
|
|
{10, 9, 8, 7, 6, 5, 4, 3, 2, 1}, // descending
|
|
{3, 3, 3, 3}, // all equal
|
|
} {
|
|
h := newStoreHeap(len(scores), scoreDesc)
|
|
for _, s := range scores {
|
|
h.add(makeScoreDoc(s))
|
|
checkTernaryInvariant(t, h)
|
|
}
|
|
// Root must be the minimum score in the heap.
|
|
min := scores[0]
|
|
for _, s := range scores[1:] {
|
|
if s < min {
|
|
min = s
|
|
}
|
|
}
|
|
if h.heap[0].Score != min {
|
|
t.Errorf("root score %.2f, want min %.2f (scores %v)", h.heap[0].Score, min, scores)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTernaryHeapRemoveLastAscending verifies that successive removeLast calls
|
|
// return elements in ascending score order (worst first = heap-sort property).
|
|
func TestTernaryHeapRemoveLastAscending(t *testing.T) {
|
|
scores := []float64{5, 3, 8, 1, 7, 2, 9, 4, 6, 10}
|
|
h := newStoreHeap(len(scores), scoreDesc)
|
|
for _, s := range scores {
|
|
h.add(makeScoreDoc(s))
|
|
}
|
|
|
|
prev := -1.0
|
|
for len(h.heap) > 0 {
|
|
doc := h.removeLast()
|
|
if doc.Score < prev {
|
|
t.Errorf("removeLast out of order: got %.2f after %.2f", doc.Score, prev)
|
|
}
|
|
prev = doc.Score
|
|
checkTernaryInvariant(t, h)
|
|
}
|
|
}
|
|
|
|
// TestTernaryHeapAddNotExceedingSize checks that size enforcement works:
|
|
// adding more than k docs keeps the best k and evicts the rest.
|
|
func TestTernaryHeapAddNotExceedingSize(t *testing.T) {
|
|
const k = 5
|
|
h := newStoreHeap(k, scoreDesc)
|
|
for i := 1; i <= 10; i++ {
|
|
evicted := h.AddNotExceedingSize(makeScoreDoc(float64(i)), k)
|
|
if i <= k {
|
|
if evicted != nil {
|
|
t.Errorf("insert %d: expected no eviction, got score %.2f", i, evicted.Score)
|
|
}
|
|
} else {
|
|
if evicted == nil {
|
|
t.Errorf("insert %d: expected eviction", i)
|
|
}
|
|
}
|
|
checkTernaryInvariant(t, h)
|
|
}
|
|
if h.Len() != k {
|
|
t.Errorf("heap size %d, want %d", h.Len(), k)
|
|
}
|
|
// The heap should hold the best k scores (6..10); root is their minimum.
|
|
if h.heap[0].Score != 6.0 {
|
|
t.Errorf("root score %.2f, want 6.00 (worst of top-%d)", h.heap[0].Score, k)
|
|
}
|
|
}
|
|
|
|
// TestTernaryHeapFinalOrder verifies that Final(0, ...) returns results in
|
|
// descending score order (best first).
|
|
func TestTernaryHeapFinalOrder(t *testing.T) {
|
|
scores := []float64{3, 1, 4, 1, 5, 9, 2, 6, 5, 3}
|
|
h := newStoreHeap(len(scores), scoreDesc)
|
|
for _, s := range scores {
|
|
h.add(makeScoreDoc(s))
|
|
}
|
|
|
|
fixup := func(*search.DocumentMatch) error { return nil }
|
|
result, err := h.Final(0, fixup)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(result) != len(scores) {
|
|
t.Fatalf("len(result)=%d, want %d", len(result), len(scores))
|
|
}
|
|
for i := 1; i < len(result); i++ {
|
|
if result[i].Score > result[i-1].Score {
|
|
t.Errorf("result[%d]=%.2f > result[%d]=%.2f (not sorted descending)",
|
|
i, result[i].Score, i-1, result[i-1].Score)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTernaryHeapFinalWithSkip verifies that Final(skip, ...) skips the skip
|
|
// worst results and returns the rest best-first.
|
|
func TestTernaryHeapFinalWithSkip(t *testing.T) {
|
|
// Heap holds 1..5; skip=2 should return scores [3, 2, 1] (skipping 4 and 5).
|
|
scores := []float64{1, 2, 3, 4, 5}
|
|
h := newStoreHeap(len(scores), scoreDesc)
|
|
for _, s := range scores {
|
|
h.add(makeScoreDoc(s))
|
|
}
|
|
|
|
fixup := func(*search.DocumentMatch) error { return nil }
|
|
result, err := h.Final(2, fixup)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(result) != 3 {
|
|
t.Fatalf("len(result)=%d, want 3", len(result))
|
|
}
|
|
want := []float64{3, 2, 1}
|
|
for i, w := range want {
|
|
if result[i].Score != w {
|
|
t.Errorf("result[%d]=%.2f, want %.2f", i, result[i].Score, w)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTernaryHeapLargeN exercises multiple levels of siftDown (n > 3^3 = 27).
|
|
func TestTernaryHeapLargeN(t *testing.T) {
|
|
const n = 200
|
|
h := newStoreHeap(n, scoreDesc)
|
|
for i := n; i >= 1; i-- { // insert descending to stress siftUp
|
|
h.add(makeScoreDoc(float64(i)))
|
|
checkTernaryInvariant(t, h)
|
|
}
|
|
|
|
prev := -1.0
|
|
count := 0
|
|
for len(h.heap) > 0 {
|
|
doc := h.removeLast()
|
|
if doc.Score < prev {
|
|
t.Errorf("removeLast out of order at position %d: %.2f after %.2f", count, doc.Score, prev)
|
|
}
|
|
prev = doc.Score
|
|
count++
|
|
checkTernaryInvariant(t, h)
|
|
}
|
|
if count != n {
|
|
t.Errorf("extracted %d elements, want %d", count, n)
|
|
}
|
|
}
|