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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 8b2911b5b9 [core] Stabilize scored global index top-k tie-breaking 
(#8301)
8b2911b5b9 is described below

commit 8b2911b5b9ff43b8fe1e76684e1f8096b67801a4
Author: QuakeWang <[email protected]>
AuthorDate: Mon Jun 22 17:29:48 2026 +0800

    [core] Stabilize scored global index top-k tie-breaking (#8301)
    
    `ScoredGlobalIndexResult.topK` used a min-heap ordered only by score,
    and replaced the heap head only when a new row had a strictly higher
    score. When rows tied around the top-k boundary, the retained row IDs
    could differ from the expected `score desc, rowId asc` ranking
    semantics.
---
 .../globalindex/ScoredGlobalIndexResult.java       | 17 +++--
 .../globalindex/ScoredGlobalIndexResultTest.java   | 77 ++++++++++++++++++++++
 2 files changed, 88 insertions(+), 6 deletions(-)

diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/ScoredGlobalIndexResult.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/ScoredGlobalIndexResult.java
index cc75b207a5..3faeeee7ba 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/ScoredGlobalIndexResult.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/ScoredGlobalIndexResult.java
@@ -85,17 +85,22 @@ public interface ScoredGlobalIndexResult extends 
GlobalIndexResult {
         }
 
         ScoreGetter scoreGetter = scoreGetter();
-        // Min-heap by score: the head is the smallest score so we can evict 
it when a
-        // higher-scored row arrives. This gives O(n log k) instead of O(n log 
n).
-        PriorityQueue<long[]> minHeap =
-                new PriorityQueue<>(
-                        k + 1, Comparator.comparingDouble(a -> 
Float.intBitsToFloat((int) a[1])));
+        // Min-heap whose ordering matches the global index ranking semantics 
(score desc,
+        // rowId asc): the head is the weakest candidate currently kept, i.e. 
the lowest
+        // score and, among ties, the largest rowId. A new row replaces the 
head only when
+        // it is strictly stronger, so the retained set equals a full "score 
desc, rowId asc"
+        // sort truncated to k, while keeping O(n log k) instead of O(n log n).
+        // entry: [rowId, rawScoreBits]
+        Comparator<long[]> weakestFirst =
+                Comparator.<long[]>comparingDouble(a -> 
Float.intBitsToFloat((int) a[1]))
+                        .thenComparing(Comparator.comparingLong((long[] a) -> 
a[0]).reversed());
+        PriorityQueue<long[]> minHeap = new PriorityQueue<>(k + 1, 
weakestFirst);
         for (long rowId : rowIds) {
             float score = scoreGetter.score(rowId);
             long[] entry = new long[] {rowId, Float.floatToRawIntBits(score)};
             if (minHeap.size() < k) {
                 minHeap.offer(entry);
-            } else if (score > Float.intBitsToFloat((int) minHeap.peek()[1])) {
+            } else if (weakestFirst.compare(entry, minHeap.peek()) > 0) {
                 minHeap.poll();
                 minHeap.offer(entry);
             }
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/ScoredGlobalIndexResultTest.java
 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/ScoredGlobalIndexResultTest.java
new file mode 100644
index 0000000000..756e7733cf
--- /dev/null
+++ 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/ScoredGlobalIndexResultTest.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) 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 org.apache.paimon.globalindex;
+
+import org.apache.paimon.utils.RoaringNavigableMap64;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link ScoredGlobalIndexResult}. */
+public class ScoredGlobalIndexResultTest {
+
+    @Test
+    public void testTopKBreaksBoundaryTiesByRowId() {
+        ScoredGlobalIndexResult result =
+                result(new long[] {1, 2, 3, 4}, new float[] {0.5f, 0.5f, 0.5f, 
0.9f});
+
+        RoaringNavigableMap64 topK = result.topK(2).results();
+
+        assertThat(topK.getIntCardinality()).isEqualTo(2);
+        assertThat(topK).contains(1L, 4L);
+        assertThat(topK).doesNotContain(2L, 3L);
+    }
+
+    @Test
+    public void testTopKBreaksTieGroupBySmallerRowId() {
+        ScoredGlobalIndexResult result =
+                result(new long[] {1, 2, 3, 4, 5}, new float[] {0.5f, 0.5f, 
0.5f, 0.5f, 0.9f});
+
+        // The high-scored row 5 forces a heap eviction within the 0.5 tie 
group, so this
+        // distinguishes the score-only heap ({2, 3, 5}) from the fixed heap 
({1, 2, 5}).
+        RoaringNavigableMap64 topK = result.topK(3).results();
+
+        assertThat(topK.getIntCardinality()).isEqualTo(3);
+        assertThat(topK).contains(1L, 2L, 5L);
+        assertThat(topK).doesNotContain(3L, 4L);
+    }
+
+    @Test
+    public void testTopKReturnsSameResultWhenCardinalityDoesNotExceedK() {
+        ScoredGlobalIndexResult result =
+                result(new long[] {1, 2, 3}, new float[] {0.1f, 0.2f, 0.3f});
+
+        assertThat(result.topK(3)).isSameAs(result);
+        assertThat(result.topK(5)).isSameAs(result);
+    }
+
+    private ScoredGlobalIndexResult result(long[] rowIds, float[] scores) {
+        RoaringNavigableMap64 bitmap = new RoaringNavigableMap64();
+        Map<Long, Float> scoreMap = new HashMap<>();
+        for (int i = 0; i < rowIds.length; i++) {
+            bitmap.add(rowIds[i]);
+            scoreMap.put(rowIds[i], scores[i]);
+        }
+        return ScoredGlobalIndexResult.create(bitmap, scoreMap::get);
+    }
+}

Reply via email to