Skip to content

Make search preconditions independent of store state - #22

Merged
tvanreenen merged 5 commits into
mainfrom
state-independent-search-contracts-0.6.0
Aug 21, 2026
Merged

Make search preconditions independent of store state#22
tvanreenen merged 5 commits into
mainfrom
state-independent-search-contracts-0.6.0

Conversation

@tvanreenen

@tvanreenen tvanreenen commented Aug 21, 2026

Copy link
Copy Markdown
Owner

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_rows or metric-specific zero-query rules, and an empty within_rows selection 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

  • Validate within_rows before any empty-store or empty-selection return.
  • Require a one-dimensional sequence of unique integer row indexes.
  • Accept Python and NumPy integer-index values, convert them to NumPy's platform index type, and reject Python and NumPy booleans.
  • Keep native integer selectors on a vectorized validation path; use per-element integer-index validation only for object-valued selectors.
  • Detect nested and ragged Python selectors before dtype inference, without changing process-global warning filters during search.
  • Raise TypeError for non-integer row values, ValueError for malformed shapes or duplicates, and IndexError for indexes outside the current store.
  • Prepare queries once in the shared search pipeline. Cosine search always normalizes its query; dot-product and Euclidean searches normalize it when the store uses normalize=True.
  • Keep zero queries valid for raw dot-product and Euclidean searches, where no query normalization is required.

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_rows should 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 by dimensions, top_k, and get(). The canonical selector uses np.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_query decision 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_k validation, 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 --check
  • uv run ruff check .
  • uv run ruff format --check .
  • uv run mypy src
  • uv run pytest --cov=numpy_vector_store --cov-report=term-missing -q — 278 tests passed, 98% source coverage
  • External mypy consumer probe covering Python lists, NumPy-scalar lists, and NumPy integer arrays as within_rows — passed
  • Million-row np.intp selector probe — approximately 50 ms per validation run after restoring the vectorized path, compared with approximately 230 ms before the review fix

Minimum supported boundary:

  • Python 3.11 with NumPy 1.23.2 — 278 tests passed with warnings treated as errors

Commit organization

  1. Validate row selectors before search shortcuts
  2. Apply zero-query rules before empty results
  3. Document search selector and zero-query rules
  4. Keep native row selector validation vectorized
  5. Avoid global warning state during row validation

Review 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.

@tvanreenen tvanreenen self-assigned this Aug 21, 2026
@tvanreenen
tvanreenen marked this pull request as ready for review August 21, 2026 18:14
@tvanreenen
tvanreenen merged commit 9680672 into main Aug 21, 2026
6 checks passed
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.

1 participant