Make search preconditions independent of store state - #22
Merged
Conversation
tvanreenen
marked this pull request as ready for review
August 21, 2026 18:14
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why this change
Search validation currently has an accidental dependency on how many rows are available. An empty store can return no hits before checking
within_rowsor metric-specific zero-query rules, and an emptywithin_rowsselection can skip query normalization on a populated store. The same call can therefore succeed or fail after rows are added even though none of its arguments changed.Duplicate row indexes are another consequence of leaving selector behavior implicit: the same stored row can be searched and returned more than once. This PR gives row selectors and zero queries one predictable contract across all three metrics.
What changed
within_rowsbefore any empty-store or empty-selection return.TypeErrorfor non-integer row values,ValueErrorfor malformed shapes or duplicates, andIndexErrorfor indexes outside the current store.normalize=True.User impact
Valid searches return the same rankings, values, metadata, and tie ordering as before. Existing empty selectors still return no hits when the query and selector are valid, and raw dot-product and Euclidean searches continue to accept zero queries.
Calls that previously succeeded only because there were no rows or no selected rows now report the underlying input error. This includes malformed, non-integer, duplicate, or out-of-bounds selectors and zero queries for cosine or normalized searches. Applications that intentionally repeated a row index in
within_rowsshould deduplicate the selector before calling the store.Design details
Selector validation remains private and colocated with
VectorStore. Native integer arrays and sequences that NumPy can infer as integer-valued retain their native dtype for vectorized uniqueness and bounds checks. Object-valued sequences fall back to the same integer-index protocol used bydimensions,top_k, andget(). The canonical selector usesnp.intp, the dtype NumPy uses for indexing. Python sequences receive a small nested-shape preflight so ragged inputs are handled consistently across NumPy versions without temporarily changing the process-wide warnings configuration; immutable stores therefore retain their documented concurrent-search behavior.Query normalization moved from the individual metric-value functions into the shared search pipeline. A small
normalize_querydecision from each public metric method makes the difference between cosine, normalized dot/Euclidean, and raw dot/Euclidean explicit. The prepared query is then reused for computation, so validation does not add a second normalization pass.This PR does not change vector conversion, threshold or
top_kvalidation, ranking algorithms, archive persistence, dependencies, supported Python versions, or the minimum NumPy version.Documentation
The README now explains selector shape, uniqueness, integer, and bounds requirements. It also records which searches reject zero queries and clarifies that an empty store or selector does not bypass validation.
Verification
Locked Python 3.13 environment:
uv lock --checkuv run ruff check .uv run ruff format --check .uv run mypy srcuv run pytest --cov=numpy_vector_store --cov-report=term-missing -q— 278 tests passed, 98% source coveragewithin_rows— passednp.intpselector probe — approximately 50 ms per validation run after restoring the vectorized path, compared with approximately 230 ms before the review fixMinimum supported boundary:
Commit organization
Validate row selectors before search shortcutsApply zero-query rules before empty resultsDocument search selector and zero-query rulesKeep native row selector validation vectorizedAvoid global warning state during row validationReview focus
The most useful review is whether selector failure categories are clear, zero-query behavior matches each metric's normalization semantics, validation truly precedes every empty-result shortcut, ordinary valid searches remain unchanged, native integer selectors stay on the vectorized path, and selector conversion avoids shared mutable warning state.