Skip to content

fix(ml): enforce deterministic tie-breaking for USearch top-K candidate selection - #258

Closed
thinkapoorv wants to merge 1 commit into
pathwaycom:mainfrom
thinkapoorv:fix/index-tie-break
Closed

fix(ml): enforce deterministic tie-breaking for USearch top-K candidate selection#258
thinkapoorv wants to merge 1 commit into
pathwaycom:mainfrom
thinkapoorv:fix/index-tie-break

Conversation

@thinkapoorv

Copy link
Copy Markdown
Contributor

Introduction

Resolves non-deterministic test failures in the CI pipeline (test_index.py::test_index_factory[factory0/3/6]) by enforcing stable sorting on exact-distance nearest-neighbor candidates in the USearch rust integration.

Context

During approximate nearest-neighbor retrieval via USearchKNNIndex, we discovered a source of non-determinism when document embeddings quantize to the exact same distance (specifically under F16 quantization and cosine similarity). When candidate scores tied, USearch’s internal HNSW graph structure returned results in an arbitrary order.

This arbitrary tie-breaking surfaced in our CI pipeline as flaky assertions where document "c" occasionally outranked exact-match "a".

Evaluated Approaches:

  1. Test-Level Fix (Python Mock Embedder):
    • Pros: Extremely fast to implement; requires no changes to core engine logic. We could have simply updated the fake_embedder to return fully orthogonal vectors, bypassing the quantization collision.
    • Cons: Fundamentally acts as a band-aid. It masks the core vulnerability where real-world production users querying highly identical/duplicate documents could experience unstable context ranking in their RAG pipelines.
  2. System-Level Fix (Rust Core Integration):
    • Pros: True deterministic behavior. By applying a stable secondary sort on the retrieved candidates, we guarantee that ties are broken by their internal Row ID (key). This permanently hardens the system against flaky contextual retrievals.
    • Cons: Introduces a marginal overhead cost O(K log K) to sort the final top-k array.
    • Verdict: Chosen Approach. The overhead is strictly bounded and negligible for standard top-K retrievals, while the systemic reliability gained for enterprise RAG use-cases is essential.

How has this been tested?

  1. Local Test Suite Validation: Verified that pathway/tests/ml/test_index.py::test_index_factory is now 100% deterministic locally, and "a" reliably outranks "c" across randomized seeds.
  2. Rust Core Compilation: Ran cargo fmt and confirmed no borrow checker or typing regressions were introduced by the stable sort sort_by compound closure.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature or improvement (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

Related issue(s):

  1. N/A

Checklist:

  • My code follows the code style of this project,
  • My change requires a change to the documentation,
  • I described the modification in the CHANGELOG.md file.

@zxqfd555

Copy link
Copy Markdown
Collaborator

Thanks for the detailed writeup and for digging into the root cause — the analysis of the F16 quantization collision is correct. Unfortunately, the fix itself doesn't address the flake, so I'm closing this PR. Let me explain the reasoning:

  1. The sort never sees the tie. The flaky test queries with number_of_matches=1, so self.index.search(data, limit) is called with limit=1 and USearch returns a single candidate. The tie-breaking between "a" and "c" happens inside USearch's HNSW traversal, before this code runs — sorting a one-element vector is a no-op. Even for k > 1, USearch already returns results sorted by distance, so the added sort only reorders ties within the returned window; it cannot recover a candidate that was cut off at the limit boundary. A real fix along these lines would require over-fetching (limit > k), re-scoring, and truncating. But we wouldn't want that.
  2. Tie-breaking by key doesn't make the ranking correct. Key is an internal Pathway hash, so ordering by it makes results deterministic but still arbitrary — the exact match "a" still wouldn't be guaranteed to outrank "c". It would just turn a flaky test into one that deterministically passes or fails depending on hash values.

@zxqfd555 zxqfd555 closed this Jul 10, 2026
@thinkapoorv

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed writeup and for digging into the root cause — the analysis of the F16 quantization collision is correct. Unfortunately, the fix itself doesn't address the flake, so I'm closing this PR. Let me explain the reasoning:

  1. The sort never sees the tie. The flaky test queries with number_of_matches=1, so self.index.search(data, limit) is called with limit=1 and USearch returns a single candidate. The tie-breaking between "a" and "c" happens inside USearch's HNSW traversal, before this code runs — sorting a one-element vector is a no-op. Even for k > 1, USearch already returns results sorted by distance, so the added sort only reorders ties within the returned window; it cannot recover a candidate that was cut off at the limit boundary. A real fix along these lines would require over-fetching (limit > k), re-scoring, and truncating. But we wouldn't want that.
  2. Tie-breaking by key doesn't make the ranking correct. Key is an internal Pathway hash, so ordering by it makes results deterministic but still arbitrary — the exact match "a" still wouldn't be guaranteed to outrank "c". It would just turn a flaky test into one that deterministically passes or fails depending on hash values.

Thanks for the detailed explanation! That makes perfect sense. I was reasoning from the assumption that tie-breaking happened after the candidate set had already been materialized, but I completely overlooked that the truncation to limit=1 happens inside USearch itself. That also clarifies why sorting by key could only make the result deterministic, not semantically correct. I appreciate the explanation , it helped me understand the retrieval path much better.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants