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

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


The following commit(s) were added to refs/heads/main by this push:
     new 91c133ae fix(lumina): drop unfilled search slots and rank NaN scores 
last (#725)
91c133ae is described below

commit 91c133aedc9bbb9eba4ad8028b738ce58d2e2a72
Author: jackylee <[email protected]>
AuthorDate: Thu Aug 27 15:46:19 2026 +0800

    fix(lumina): drop unfilled search slots and rank NaN scores last (#725)
---
 crates/paimon/src/lumina/reader.rs | 190 +++++++++++++++++++++++++++++++++++--
 1 file changed, 180 insertions(+), 10 deletions(-)

diff --git a/crates/paimon/src/lumina/reader.rs 
b/crates/paimon/src/lumina/reader.rs
index 9c365f4c..25514a67 100644
--- a/crates/paimon/src/lumina/reader.rs
+++ b/crates/paimon/src/lumina/reader.rs
@@ -45,6 +45,39 @@ fn convert_distance_to_score(distance: f32, metric: 
LuminaVectorMetric) -> f32 {
     }
 }
 
+/// Order two search scores, best last, with NaN ranked below every real score.
+///
+/// This is the score-domain mirror of `vindex::pkvector::metric`'s
+/// `java_float_compare`, which ranks a NaN *distance* worst. `f32::total_cmp`
+/// alone is unsuitable in either domain: it places a positive NaN above every
+/// finite value, so a NaN score -- reachable here because a non-finite stored
+/// vector yields a NaN distance and [`convert_distance_to_score`] passes NaN
+/// through -- would outrank real neighbours. Both NaN signs lose, and two NaNs
+/// compare equal so the caller's row-id tie-break decides between them.
+fn compare_scores(a: f32, b: f32) -> std::cmp::Ordering {
+    match (a.is_nan(), b.is_nan()) {
+        (true, true) => std::cmp::Ordering::Equal,
+        (true, false) => std::cmp::Ordering::Less,
+        (false, true) => std::cmp::Ordering::Greater,
+        (false, false) => a.total_cmp(&b),
+    }
+}
+
+/// Allocate the label buffer for one native search, filled with [`SENTINEL`].
+///
+/// [`SENTINEL`] is the "no result" marker (the C ABI's `-1`), and
+/// [`collect_results`] drops any slot carrying it. Zero-filling the buffer
+/// instead makes that marker unreliable: `0` is a *legal* row id, so a slot 
the
+/// searcher leaves untouched is indistinguishable from a real hit, and it 
pairs
+/// with distance `0.0` -- the best distance both L2 and cosine can report -- 
so
+/// a search returning fewer neighbours than requested would surface row 0 as 
its
+/// top match. The FFI reports only a status code, never how many slots it 
wrote,
+/// so the Rust side cannot detect a short return; allocating the sentinel is
+/// correct either way.
+fn new_label_buffer(len: usize) -> Vec<u64> {
+    vec![SENTINEL; len]
+}
+
 /// Post-filter search results to top_k.
 fn collect_results(
     labels: &[u64],
@@ -64,8 +97,21 @@ fn collect_results(
         }
     }
     impl Ord for ScoredRow {
+        // Reversed on score so the heap top is the weakest candidate; among
+        // equal scores the larger row id sorts first and is therefore evicted
+        // first, which keeps the retained set independent of the order the
+        // searcher returned the pairs in. Same shape as
+        // `vector_search::ScoredRow`, except that scores are compared through
+        // `compare_scores` so a NaN cannot claim the strongest slot.
         fn cmp(&self, other: &Self) -> std::cmp::Ordering {
-            other.score.total_cmp(&self.score)
+            compare_scores(other.score, self.score).then_with(|| 
self.row_id.cmp(&other.row_id))
+        }
+    }
+
+    impl ScoredRow {
+        fn is_stronger_than(&self, other: &Self) -> bool {
+            compare_scores(self.score, other.score).then_with(|| 
other.row_id.cmp(&self.row_id))
+                == std::cmp::Ordering::Greater
         }
     }
 
@@ -75,13 +121,15 @@ fn collect_results(
             continue;
         }
         let score = convert_distance_to_score(distance, metric);
+        let entry = ScoredRow { row_id, score };
         if min_heap.len() < top_k {
-            min_heap.push(ScoredRow { row_id, score });
-        } else if let Some(peek) = min_heap.peek() {
-            if score > peek.score {
-                min_heap.pop();
-                min_heap.push(ScoredRow { row_id, score });
-            }
+            min_heap.push(entry);
+        } else if min_heap
+            .peek()
+            .is_some_and(|weakest| entry.is_stronger_than(weakest))
+        {
+            min_heap.pop();
+            min_heap.push(entry);
         }
     }
 
@@ -272,7 +320,7 @@ fn search_lumina(
         }
         let ek = std::cmp::min(effective_k, filter_id_list.len());
         let mut distances = vec![0.0f32; ek];
-        let mut labels = vec![0u64; ek];
+        let mut labels = new_label_buffer(ek);
         let mut search_opts: HashMap<String, String> = 
search_options_base.clone();
         search_opts.insert("search.thread_safe_filter".to_string(), 
"true".to_string());
         ensure_search_list_size(&mut search_opts, ek);
@@ -288,7 +336,7 @@ fn search_lumina(
         (distances, labels)
     } else {
         let mut distances = vec![0.0f32; effective_k];
-        let mut labels = vec![0u64; effective_k];
+        let mut labels = new_label_buffer(effective_k);
         let mut search_opts: HashMap<String, String> = 
search_options_base.clone();
         ensure_search_list_size(&mut search_opts, effective_k);
         searcher.search(
@@ -371,7 +419,7 @@ fn search_lumina_batch(
     }
 
     let mut distances = vec![0.0f32; vector_searches.len() * effective_k];
-    let mut labels = vec![0u64; vector_searches.len() * effective_k];
+    let mut labels = new_label_buffer(vector_searches.len() * effective_k);
     let mut search_opts: HashMap<String, String> = search_options_base.clone();
     ensure_search_list_size(&mut search_opts, effective_k);
     searcher.search(
@@ -500,6 +548,128 @@ mod tests {
         assert!(!result.contains_key(&2)); // 0.1 is lowest
     }
 
+    /// Rows sharing a score must be kept by ascending row id, not by the order
+    /// the native searcher happened to return them in. Feeding the same tied
+    /// scores in two different label orders must select the same rows.
+    #[test]
+    fn test_collect_results_breaks_ties_by_row_id() {
+        // Rows 10, 20, 30 all score 0.5; row 40 scores higher and always wins.
+        let distances = vec![0.9, 0.5, 0.5, 0.5];
+
+        let forward = collect_results(
+            &[40, 10, 20, 30],
+            &distances,
+            2,
+            LuminaVectorMetric::InnerProduct,
+        );
+        let reversed = collect_results(
+            &[40, 30, 20, 10],
+            &distances,
+            2,
+            LuminaVectorMetric::InnerProduct,
+        );
+
+        let mut forward_ids: Vec<u64> = forward.keys().copied().collect();
+        forward_ids.sort_unstable();
+        let mut reversed_ids: Vec<u64> = reversed.keys().copied().collect();
+        reversed_ids.sort_unstable();
+
+        assert_eq!(
+            forward_ids, reversed_ids,
+            "tied rows must not depend on label order"
+        );
+        assert_eq!(
+            forward_ids,
+            vec![10, 40],
+            "among equal scores the smallest row id wins"
+        );
+    }
+
+    /// The same invariant when every candidate ties: the retained set is the
+    /// `top_k` smallest row ids regardless of input order.
+    #[test]
+    fn test_collect_results_all_tied_keeps_smallest_row_ids() {
+        let distances = vec![0.25; 5];
+
+        let forward = collect_results(
+            &[1, 2, 3, 4, 5],
+            &distances,
+            3,
+            LuminaVectorMetric::InnerProduct,
+        );
+        let shuffled = collect_results(
+            &[4, 1, 5, 3, 2],
+            &distances,
+            3,
+            LuminaVectorMetric::InnerProduct,
+        );
+
+        let mut forward_ids: Vec<u64> = forward.keys().copied().collect();
+        forward_ids.sort_unstable();
+        let mut shuffled_ids: Vec<u64> = shuffled.keys().copied().collect();
+        shuffled_ids.sort_unstable();
+
+        assert_eq!(forward_ids, vec![1, 2, 3]);
+        assert_eq!(shuffled_ids, vec![1, 2, 3]);
+    }
+
+    /// A NaN score must never outrank a finite one. `f32::total_cmp` alone 
ranks
+    /// a positive NaN above every finite value, so using it here would let a 
NaN
+    /// score -- which a non-finite stored vector can produce -- take the only
+    /// top-1 slot. Both NaN signs must lose, in either arrival order.
+    #[test]
+    fn test_collect_results_ranks_nan_below_finite_scores() {
+        for (labels, distances) in [
+            (vec![7u64, 8], vec![f32::NAN, 0.5]),
+            (vec![8u64, 7], vec![0.5, f32::NAN]),
+            (vec![7u64, 8], vec![-f32::NAN, 0.5]),
+            (vec![8u64, 7], vec![0.5, -f32::NAN]),
+        ] {
+            let result = collect_results(&labels, &distances, 1, 
LuminaVectorMetric::InnerProduct);
+            assert_eq!(result.len(), 1);
+            assert!(
+                result.contains_key(&8),
+                "the finite score must win regardless of arrival order, got 
{result:?}"
+            );
+        }
+    }
+
+    /// The label buffer handed to the native searcher must start out as
+    /// [`SENTINEL`], never zero -- row id `0` is a legal result.
+    #[test]
+    fn test_new_label_buffer_is_sentinel_filled() {
+        assert_eq!(new_label_buffer(3), vec![SENTINEL; 3]);
+        assert!(new_label_buffer(0).is_empty());
+    }
+
+    /// A search that returns fewer neighbours than requested leaves the tail 
of
+    /// the buffer exactly as it was allocated. Those slots must not surface as
+    /// rows: with a zero-filled buffer the tail reads as row `0` at distance
+    /// `0.0`, and `1.0 / (1.0 + 0.0)` is the highest score L2 can produce, so
+    /// row 0 would be reported as the best match for every short result.
+    #[test]
+    fn test_unfilled_label_slots_are_not_reported_as_hits() {
+        let mut labels = new_label_buffer(4);
+        let mut distances = vec![0.0f32; 4];
+        labels[0] = 11;
+        distances[0] = 3.0;
+        labels[1] = 22;
+        distances[1] = 7.0;
+
+        let result = collect_results(&labels, &distances, 4, 
LuminaVectorMetric::L2);
+
+        assert_eq!(
+            result.len(),
+            2,
+            "only the slots the searcher filled may be reported, got 
{result:?}"
+        );
+        assert!(result.contains_key(&11) && result.contains_key(&22));
+        assert!(
+            !result.contains_key(&0),
+            "an untouched slot must not become row 0"
+        );
+    }
+
     #[test]
     fn test_reader_new() {
         let m = GlobalIndexIOMeta::new("a".into(), 100, vec![]);

Reply via email to