Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pip install "numpy<2.5.0"

- name: ruff check
run: ruff check snapvec/ tests/
Expand Down Expand Up @@ -61,6 +62,7 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pip install "numpy<2.5.0"

- name: Run tests
run: pytest -q --cov=snapvec --cov-report=term-missing
Expand Down
4 changes: 4 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 2024-05-18 - Fast row-wise Euclidean norm in pure NumPy
**Learning:** In performance-critical paths, computing the batch norm of a 2D array via `np.linalg.norm(arr, axis=1)` is relatively slow. Using `np.sqrt(np.einsum('ij,ij->i', arr, arr))` is significantly faster (~4x speedup on a laptop CPU for typical batch sizes). If `keepdims=True` behavior is needed, appending `[:, np.newaxis]` matches the original shape seamlessly.
**Action:** Always prefer `np.sqrt(np.einsum('ij,ij->i', arr, arr))` over `np.linalg.norm(arr, axis=1)` when computing row-wise vector norms in NumPy to eliminate dispatch overhead and improve execution speed.

## 2024-05-23 - Fast chunked batching for file writes
**Learning:** Batching multiple small file writes into a single `bytearray` before calling `f.write()` significantly improves serialization performance (approx. 1.4x speedup) by reducing system call overhead and frequent `zlib.crc32` updates. Implementing a chunked batching strategy (flushing at 64KB) prevents unbounded memory usage, while allowing large incoming data chunks (>= 64KB) to bypass the buffer to prevent unnecessary memory allocations.
Comment on lines +5 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ“ Maintainability & Code Quality | 🟑 Minor | ⚑ Quick win

Insert a blank line after the new heading.

markdownlint-cli2 reports MD022 on Line 5 because the heading is followed immediately by **Learning:** on Line 6. Add one blank line after the heading.

🧰 Tools
πŸͺ› markdownlint-cli2 (0.23.2)

[warning] 5-5: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.jules/bolt.md around lines 5 - 6, Update the β€œ2024-05-23 - Fast chunked
batching for file writes” section in bolt.md by inserting one blank line between
the heading and the β€œLearning:” paragraph, resolving the MD022 spacing
violation.

Source: Linters/SAST tools

**Action:** Use chunked `bytearray` batching when performing many small file writes to reduce I/O and CPU overhead.
6 changes: 3 additions & 3 deletions snapvec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@

__version__ = "0.11.1"
__all__ = [
"SnapIndex",
"PQSnapIndex",
"IVFPQSnapIndex",
"PQSnapIndex",
"ResidualSnapIndex",
"SnapIndex",
"get_codebook",
"rht",
"padded_dim",
"rht",
]
2 changes: 0 additions & 2 deletions snapvec/_fast.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@ The real module is built from Cython and does not ship a ``.pyi``
from the compiler; this stub lets ``mypy --strict`` see the same
Python-level shapes the Cython kernels expose to callers.
"""
from __future__ import annotations

import numpy as np
from numpy.typing import NDArray


def adc_colmajor(
lut: NDArray[np.float32],
codes: NDArray[np.uint8],
Expand Down
42 changes: 31 additions & 11 deletions snapvec/_file_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@
import os
import struct
import zlib
from collections.abc import Callable
from pathlib import Path
from types import TracebackType
from typing import IO, Callable

from typing import IO

_TRAILER_MAGIC = b"CRC2"
_TRAILER_SIZE = 8 # 4 bytes magic + 4 bytes uint32 CRC
Expand Down Expand Up @@ -60,26 +60,47 @@ def __init__(self, f: IO[bytes]) -> None:
self._f = f
self._crc = 0
self._finalised = False
self._buffer = bytearray()

def write(self, data: bytes) -> int:
def write(self, data: bytes | bytearray) -> int:
if self._finalised:
raise RuntimeError(
"ChecksumWriter.write called after finalise(); the "
"trailer has already been emitted."
)
self._crc = zlib.crc32(data, self._crc)
return self._f.write(data)

n = len(data)
# Fast path: bypass buffer for large writes to avoid copying
if n >= 65536:
if self._buffer:
self.flush()
self._crc = zlib.crc32(data, self._crc)
self._f.write(data)
return n

self._buffer.extend(data)
if len(self._buffer) >= 65536:
self.flush()
return n

def flush(self) -> None:
"""Flush the internal buffer to the underlying file."""
if self._buffer:
self._crc = zlib.crc32(self._buffer, self._crc)
self._f.write(self._buffer)
Comment on lines +77 to +90

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ—„οΈ Data Integrity & Integration | 🟑 Minor | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

rg -n -C 8 '\bChecksumWriter\s*\(' --glob '*.py'
rg -n -C 5 'BytesIO|FileIO|BufferedWriter|open\(.*wb' --glob '*.py'

Repository: stffns/snapvec

Length of output: 152


🏁 Script executed:

set -eu
printf '%s\n' '--- tracked Python files ---'
git ls-files '*.py' | sed -n '1,120p'
printf '%s\n' '--- candidate files ---'
fd -i '_file_format\.py$|snapvec' . 2>/dev/null | sed -n '1,120p'
printf '%s\n' '--- writer definition and references ---'
rg -n -C 12 'class ChecksumWriter|ChecksumWriter|def flush|_f\.write|zlib\.crc32' . --glob '*.py' || true

Repository: stffns/snapvec

Length of output: 28475


🏁 Script executed:

set -eu
printf '%s\n' '--- ChecksumWriter implementation ---'
sed -n '59,191p' snapvec/_file_format.py
printf '%s\n' '--- all production save wrappers ---'
rg -n -C 4 'save_with_checksum_atomic|ChecksumWriter\(' snapvec examples tests --glob '*.py'
printf '%s\n' '--- direct file-like test coverage ---'
rg -n -C 6 'BytesIO|FileIO|BufferedWriter|short|partial|write\(' tests/test_file_format.py --glob '*.py' || true
printf '%s\n' '--- standalone short-write behavior probe ---'
python3 - <<'PY'
import struct
import zlib

class ShortWriter:
    def __init__(self, limit):
        self.limit = limit
        self.data = bytearray()
    def write(self, payload):
        n = min(len(payload), self.limit)
        self.data.extend(payload[:n])
        return n

class Reproduction:
    def __init__(self, f):
        self._f = f
        self._crc = 0
        self._buffer = bytearray()
    def write(self, data):
        n = len(data)
        if n >= 65536:
            if self._buffer:
                self.flush()
            self._crc = zlib.crc32(data, self._crc)
            self._f.write(data)
            return n
        self._buffer.extend(data)
        if len(self._buffer) >= 65536:
            self.flush()
        return n
    def flush(self):
        if self._buffer:
            self._crc = zlib.crc32(self._buffer, self._crc)
            self._f.write(self._buffer)
            self._buffer.clear()
    def finalise(self):
        self.flush()
        self._f.write(b"CRC2")
        self._f.write(struct.pack("<I", self._crc & 0xffffffff))

payload = b"x" * 65536
sink = ShortWriter(1024)
writer = Reproduction(sink)
returned = writer.write(payload)
writer.finalise()
stored = struct.unpack("<I", sink.data[-4:])[0]
actual = zlib.crc32(sink.data[:-8]) & 0xffffffff
print({"write_returned": returned, "payload_length": len(payload),
       "persisted_payload": len(sink.data) - 8,
       "stored_crc": stored, "actual_crc": actual,
       "crc_matches": stored == actual})
PY

Repository: stffns/snapvec

Length of output: 13509


Handle short writes before committing the checksum.

Production call sites use regular files, but ChecksumWriter accepts generic IO[bytes]. A short write can make write() report success, discard payload bytes, and produce an invalid trailer. Add write-all handling for payload and trailer writes, or restrict the API to full-write streams.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@snapvec/_file_format.py` around lines 77 - 90, Update ChecksumWriter’s
buffered and direct write paths, including flush and trailer writing, to handle
short writes by retrying until all bytes are persisted before updating or
committing the checksum. Preserve the existing behavior for full-write streams
and ensure partial writes cannot discard payload or trailer bytes.

self._buffer.clear()
Comment on lines +63 to +91

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | πŸ”΅ Trivial | ⚑ Quick win

Add boundary tests for the buffering contract.

The supplied round-trip test in tests/test_file_format.py, Lines 156-172, verifies end-to-end integrity but does not isolate the new branches. Add tests for:

  • a 65,535-byte write followed by a 1-byte write;
  • an exact 65,536-byte write;
  • buffered data followed by a large direct write;
  • bytes and bytearray inputs;
  • CRC verification for each sequence.
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@snapvec/_file_format.py` around lines 63 - 91, Add focused tests for
ChecksumWriter.write and flush covering a 65,535-byte write followed by one
byte, an exact 65,536-byte write, and buffered data followed by a large direct
write. Exercise both bytes and bytearray inputs, finalize each sequence, and
verify the emitted data and CRC against the expected zlib checksum.


def finalise(self) -> None:
"""Write the trailer. Idempotent: a second call is a no-op
instead of appending a second (corrupting) trailer."""
if self._finalised:
return
self.flush()
self._f.write(_TRAILER_MAGIC)
self._f.write(struct.pack("<I", self._crc & 0xFFFFFFFF))
self._finalised = True

def __enter__(self) -> "ChecksumWriter":
def __enter__(self) -> "ChecksumWriter": # noqa: PYI034, UP037
return self

def __exit__(
Expand Down Expand Up @@ -163,16 +184,15 @@ def save_with_checksum_atomic(
"""
path = Path(path)
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "wb") as raw:
with ChecksumWriter(raw) as cw:
writer_fn(cw)
with open(tmp, "wb") as raw, ChecksumWriter(raw) as cw:
writer_fn(cw)
os.replace(tmp, path)


__all__ = [
"ChecksumWriter",
"has_trailer",
"verify_checksum",
"trailer_len",
"save_with_checksum_atomic",
"trailer_len",
"verify_checksum",
]
4 changes: 2 additions & 2 deletions snapvec/_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,7 @@ def save(self, path: str | Path) -> None:
else:
packed = _pack(self._indices, self._mse_bits)

def _write(f: "ChecksumWriter") -> None:
def _write(f: ChecksumWriter) -> None:
f.write(_MAGIC)
f.write(struct.pack("<IIIIII", _VERSION, self.dim, self.bits, self.seed, n, flags))
f.write(struct.pack("<I", len(packed)))
Expand All @@ -599,7 +599,7 @@ def _write(f: "ChecksumWriter") -> None:
save_with_checksum_atomic(path, _write)

@classmethod
def load(cls, path: str | Path) -> "SnapIndex":
def load(cls, path: str | Path) -> SnapIndex:
"""Load index from a ``.snpv`` file.

Supports v1 (mse-only legacy) and v2 (prod/flags) formats.
Expand Down
4 changes: 2 additions & 2 deletions snapvec/_ivfpq.py
Original file line number Diff line number Diff line change
Expand Up @@ -1127,7 +1127,7 @@ def save(self, path: str | Path) -> None:
flags |= _FLAG_USE_OPQ
n = len(self._ids_by_row)

def _write(f: "ChecksumWriter") -> None:
def _write(f: ChecksumWriter) -> None:
f.write(_MAGIC)
f.write(
struct.pack(
Expand Down Expand Up @@ -1170,7 +1170,7 @@ def _write(f: "ChecksumWriter") -> None:
save_with_checksum_atomic(path, _write)

@classmethod
def load(cls, path: str | Path) -> "IVFPQSnapIndex":
def load(cls, path: str | Path) -> IVFPQSnapIndex:
path = Path(path)
verify_checksum(path) # no-op for legacy files without a trailer
with open(path, "rb") as f:
Expand Down
6 changes: 3 additions & 3 deletions snapvec/_kmeans.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,9 @@ def fit_opq_rotation(


__all__ = [
"kmeans_pp_init",
"kmeans_mse",
"assign_l2",
"probe_scores_l2_monotone",
"fit_opq_rotation",
"kmeans_mse",
"kmeans_pp_init",
"probe_scores_l2_monotone",
]
4 changes: 2 additions & 2 deletions snapvec/_pq.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ def save(self, path: str | Path) -> None:
flags |= _FLAG_USE_OPQ
n = len(self._ids)

def _write(f: "ChecksumWriter") -> None:
def _write(f: ChecksumWriter) -> None:
f.write(_MAGIC)
f.write(
struct.pack(
Expand Down Expand Up @@ -459,7 +459,7 @@ def _write(f: "ChecksumWriter") -> None:
save_with_checksum_atomic(path, _write)

@classmethod
def load(cls, path: str | Path) -> "PQSnapIndex":
def load(cls, path: str | Path) -> PQSnapIndex:
path = Path(path)
verify_checksum(path) # no-op for legacy files without a trailer
with open(path, "rb") as f:
Expand Down
5 changes: 2 additions & 3 deletions snapvec/_residual.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
from ._freezable import FreezableIndex
from ._rotation import padded_dim, rht


_MAX_ID_BYTES = 0xFFFF # file format stores id length as uint16


Expand Down Expand Up @@ -295,7 +294,7 @@ def save(self, path: str | Path) -> None:
flags |= 1
n = len(self._ids)

def _write(f: "ChecksumWriter") -> None:
def _write(f: ChecksumWriter) -> None:
f.write(_MAGIC)
f.write(struct.pack("<IIIIIIII", _VERSION, self.dim, self.b1,
self.b2, self.seed, n, flags, self._pdim))
Expand All @@ -318,7 +317,7 @@ def _write(f: "ChecksumWriter") -> None:
save_with_checksum_atomic(path, _write)

@classmethod
def load(cls, path: str | Path) -> "ResidualSnapIndex":
def load(cls, path: str | Path) -> ResidualSnapIndex:
path = Path(path)
verify_checksum(path) # no-op for legacy files without a trailer
with open(path, "rb") as f:
Expand Down
1 change: 0 additions & 1 deletion tests/test_adversarial.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@

from snapvec import IVFPQSnapIndex, PQSnapIndex, ResidualSnapIndex, SnapIndex


# --------------------------------------------------------------------------- #
# Empty index #
# --------------------------------------------------------------------------- #
Expand Down
4 changes: 2 additions & 2 deletions tests/test_file_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,8 @@ def test_truncated_trailer_falls_back_to_legacy_mode(tmp_path: Path) -> None:
# ──────────────────────────────────────────────────────────────────── #

@pytest.mark.parametrize("index_cls, ctor_kwargs, suffix", [
(SnapIndex, dict(dim=32, bits=4, normalized=True), ".snpv"),
(ResidualSnapIndex, dict(dim=32, b1=3, b2=3, normalized=True), ".snpr"),
(SnapIndex, {"dim": 32, "bits": 4, "normalized": True}, ".snpv"),
(ResidualSnapIndex, {"dim": 32, "b1": 3, "b2": 3, "normalized": True}, ".snpr"),
])
def test_trailing_crc_roundtrip_trainingfree(
index_cls, ctor_kwargs, suffix, tmp_path,
Expand Down
1 change: 0 additions & 1 deletion tests/test_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@

from snapvec import IVFPQSnapIndex, PQSnapIndex, SnapIndex


PROFILE = settings(
max_examples=25,
deadline=None,
Expand Down
6 changes: 4 additions & 2 deletions tests/test_snapvec.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ def test_legacy_v2_3bit_file_loads_via_compat_decoder(self, tmp_path):
path, then re-pack into the new tight RAM layout.
"""
import struct

from snapvec._index import _MAGIC

idx = SnapIndex(dim=128, bits=3)
Expand Down Expand Up @@ -212,7 +213,8 @@ def test_legacy_v2_prod_mode_3bit_payload_stays_aligned(self, tmp_path):
corrupt the prod correction term).
"""
import struct
from snapvec._index import _MAGIC, _FLAG_PROD

from snapvec._index import _FLAG_PROD, _MAGIC

# Real v3 prod-mode index to source the reference indices + payload.
idx = SnapIndex(dim=128, bits=4, use_prod=True)
Expand Down Expand Up @@ -473,7 +475,7 @@ def test_filter_restricts_results(self):
idx = SnapIndex(dim=DIM, bits=4)
idx.add_batch(list(range(100)), vecs)

allowed = set(range(0, 50))
allowed = set(range(50))
results = idx.search(vecs[0], k=10, filter_ids=allowed)
assert all(r[0] in allowed for r in results)

Expand Down
Loading