* release: 1.0.0 Cuts the Unreleased section and takes both packages to 1.0.0. The crate and the Python package had drifted to 0.9.0 and 0.8.0; they are one version from here. What 1.0 commits to is the on-disk format. v7 is what turbovec reads and writes, and a file written by this release will be readable by later ones — which is a claim worth making now that there is one format instead of two, and a converter for anything older. The release is breaking: a .tv or .tvim from any earlier release no longer loads, and is refused by version rather than misread. The changelog leads with that and with how to migrate. Versions moved in step across turbovec/Cargo.toml, turbovec-python/Cargo.toml, turbovec-python/pyproject.toml and Cargo.lock — the two release workflows check the tag against those manifests before building anything (#343), so a mismatch fails fast. Rust suite and 482 Python tests green at these versions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * release: keep [Unreleased], and update the example's lockfile Two things the release cut got wrong, both caught by CI and review. The downstream-smoke example carries its own Cargo.lock pinning the crate version, and ci.yml runs it with --locked. Bumping to 1.0.0 without it left the lock at 0.9.0, so the job failed with "cannot update the lock file because --locked was passed". Updated; the exact CI command now runs clean. And the release section replaced `## [Unreleased]` rather than sitting below it. Every previous cut kept the heading, and for a reason beyond tidiness: changelog_gate.py looks for it and, finding none, returns a pass — so deleting it would have silently disabled the changelog gate for every later PR. Restored above the 1.0.0 section. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * release: re-base the compare links for the 1.0.0 cut [Unreleased] still resolved to v0.9.0...HEAD, so once v1.0.0 is tagged that span covers everything this PR just documented as released — clicking through would show the whole 1.0.0 release as unreleased. It now starts at v1.0.0, with compare links added for both new tags. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
221 KiB
Changelog
All notable changes to turbovec are recorded here. The format is based on Keep a Changelog and the project follows Semantic Versioning.
The Rust crate (turbovec on crates.io) and the Python distribution
(turbovec on PyPI) version independently. Each release section below
is split by surface — a single feature can affect both, and its bullet
appears under each surface it touches.
Unreleased
turbovec 1.0.0 (Python package) + turbovec 1.0.0 (Rust crate) — 2026-08-18
First stable release, and the two packages are now on one version — the crate and the Python package had drifted to 0.9.0 and 0.8.0 and both go to 1.0.0 here. What 1.0 commits to is the on-disk format: v7 is what turbovec reads and writes, and a file written by this release will be readable by later ones.
Breaking: v7 is the only format turbovec reads or writes. A .tv or
.tvim file written by any earlier release no longer loads — it is
refused with an error naming its version rather than misread. Use
turbovec::convert, added in this release, to bring a v5 or v6 file
forward (or take a v7 file back); cargo run --example convert -- <in> <out> v7 does it from a shell. Files older than v5 predate the rotation
change that altered every encoded byte and can only be rebuilt from the
source vectors.
What v7 buys is sync(): saving an index that has changed writes the
rows that changed rather than the whole file, and write() / to_bytes()
now produce the same container so there is one format to reason about
instead of two.
The rest of the release is bug fixes, several of them long-lived. A delete could stall for seconds behind concurrent searches; the first small add after a load permanently doubled the codes buffer; a load allocated from a file's apparent length rather than its declared contents; a built index carried two copies of its codes for its lifetime; the stale-temp sweep never fired for long filenames; an aarch64 search got slower the moment an index crossed 32768 vectors; and Agno's async writes paid for embeddings before discovering the store was not created.
turbovec — Rust crate (current: 0.9.0 → next: 1.0.0)
Added
-
turbovec::convertconverts an index file between every format turbovec has written. v5, v6 and v7 in any direction, for both.tvand.tvim, so a file written by an older build can be brought forward — or taken back, for a rollback or to reproduce a bug against an older reader.readdecodes any of them into a version-neutralImage,writere-encodes it as any version,convert_filedoes both through a temp file and an atomic rename, andversion_ofreports what a file is without decoding it.cargo run --example convert -- <in> <out> v6is the same thing from a shell.This is the one place that still understands v5 and v6; everything else reads and writes v7 only. Converting is a re-container, not a re-quantize: the stored codes, scales, calibration and ids are carried across untouched, so search results are identical whatever route a file took. v7 output goes through the shipping writer, so a converted file is byte-identical to one this build would have produced.
What does not survive going down a version is v7's incremental state — the generation, the pending redo ops and the file's sync claim — because v5 and v6 are flat snapshots with no commit history. The lazy sentinel does survive: all three versions spell "no dimension committed" as
dim == 0with no rows, which is what the release before v7 wrote for a store saved before its first add. Files older than v5 remain undecodable and are named as such rather than guessed at.
Changed
-
v7 is the only format turbovec reads or writes.
write,write_with_durability,write_to_writerandto_bytesall emit a v7 image, on bothTurboQuantIndexandIdMapIndex;load,from_bytesandload_from_readeraccept one. A pre-v7 file is refused with an error naming its version and pointing at conversion, and a file that is not a turbovec index says so instead. A missing path still raisesNotFoundrather than a format complaint.v7 turned out to serve the byte entry points without change: its loader has always read the whole file up front and indexed around inside that buffer, so it needs a random-access slice, not a seekable file. The parser and the writer are each split into an image half and a file half, so
to_bytesandwriteproduce the same bytes by construction.Snapshots are unclaimed. A nonce answers one question for
sync— is the file at this path still the one I committed to? — so it only matters for a file some index is syncing.write/to_bytesstamp nonce 0, meaning unclaimed, andsyncclaims a file with a random one.loadwill not bind a cursor to an unclaimed file, so the first sync to a snapshot full-writes and claims it, and a cursor that meets an unclaimed file rebuilds rather than reporting a foreign writer. That keeps three properties at once:to_bytesis a pure function of index state,writeproduces exactly those bytes, and a sync still refuses to patch a file another writer replaced.The lazy sentinel survives: an index constructed without a dimension and never added to still serializes (dim 0, zero rows, no codebook) and reloads lazy, so saving a store before its first write keeps working.
Removed with the formats: both v5/v6 readers and writers, the raw
io::write*/io::load*entry points, the version dispatcher and the codebook-acceptance memo —io.rsdrops from 2814 lines to 1085. The encode fingerprint is re-frozen for the new container; every computed stage hash is unchanged, only the file hashes moved.
Fixed
- aarch64: crossing the single-query block-parallel gate no longer makes
the same query slower (#493). At
n_blocks >= 1024(n ≥ 32768) an unmaskednq=1search switches to the block-parallel scan, which ran the full 32-lane top-k loop for every block — while the sub-gate kernel it replaces has a whole-block SIMD-max prune that skips that loop once the heap is warm. The result was a discontinuity exactly at the gate: measured at dim=128, 4-bit, k=10, one thread, 77.9 µs at 1023 blocks against 105.9 µs at 1024 — 36% slower for 0.1% more data. The prune is now mirrored into the parallel scan: 105.9 → 84.9 µs at the gate, 141.7 → 120.2 µs at 1500 blocks, 183.9 → 166.8 µs at 2048, and the discontinuity is gone (85.7 µs at 1023 blocks against 84.9 at 1024). Multi-threaded is neutral to ~5% better. Results are unchanged — a block whose maximum is at or below the heap minimum holds no lane that could enter the heap. - A v6
load()no longer commits memory proportional to the file's apparent length (#487). Two allocations were sized from the file rather than from what its header declares. The tail (scales, TQ+ trailer,.tvimid table) took the whole remainder past the codes section, so a genuine 2450-byte index padded into a 4 GiB sparse file loaded correctly but peaked at 8.2 GB; the tail is now sized from declared content and still capped by the real remainder, so a truncated file fails exactly where it did. Separately,load/load_id_mapread the entire file before comparing four bytes to the magic, so pointing them at a 4 GiB non-turbovec file cost 4 GB of RSS to produce "wrong magic" — the magic is now checked from a 4 KB prefix, which reproduces every rejection message (v1's missing magic, a v7 container, the versions 1–4 rebuild error) without the read.write()always emits exact-length files, so this only ever bit on files turbovec did not write — but a sparse file makes a large apparent length nearly free to fabricate. Trailing bytes are still accepted, matchingfrom_bytesandload_from_reader. - The first small add after a load, a bulk add or a search no longer
permanently doubles the codes buffer (#501).
Vec::reservegrows amortized — onlen == capacityit takesmax(len + additional, capacity * 2)— so appending a single row to a tight buffer allocated a second full copy and kept it as capacity slack for the index's lifetime, since every later small add then fit inside it. A load, afrom_bytes, a one-shot bulk add and asearch/prepareall leave exactly that tight state, which made "load a large index, add a small delta" — the workflow v7sync()exists for — the worst case: a 2.4 GB index grew by 2.4 GB on its first incremental add. (The issue also measured a second copy from the packed rows; since #475 an add drops those at its commit point, so that one is now a peak-heap cost during the add rather than retained capacity — still worth removing, and covered by a peak-heap test.) All four growth sites (codes, scales, and the blocked cache on both the lazy-append and eager-patch paths) now reserve close to what they need when the append is at most an eighth of current length, keeping an eighth as headroom so a run of small adds stays amortized — the reserve is skipped entirely when the spare capacity already covers the append, which is what makes that headroom usable instead of merely requested. Larger appends keep amortized doubling unchanged, which is what repeated same-size batch adds rely on for O(1) growth; add throughput is unchanged single- and multi-threaded. - The stale-temp sweep works for long destination filenames. A save
writes to a
<dest>.tmp.…sibling, andtmp_siblingtruncates the destination's basename when the whole name would exceed NAME_MAX — but the sweep that reclaims temps leaked by a killed writer matched on the untruncated basename, so past about 234 bytes it never matched anything. A crash-looping writer's temps accumulated with nothing to reclaim them, which is the failure the sweep exists to prevent. The sweep now recognises the truncated form, identified precisely (a stem that prefixes the destination's basename, on a name that lands exactly on NAME_MAX) so it cannot reach an unrelated destination's temps. - A finite-but-unusable calibration no longer loads clean and NaNs every
score.
tqplus_scalewas checked forfinite && > 0, so a value like1e-40was accepted byfrom_partsand by every.tv/.tvimloader — and search, which divides by it, then returnedInf/NaNfor every score, with the top-k heap degenerating to arrival order. One poisoned coordinate out ofdimwas enough, and it round-tripped to disk. The bound is now derived from the input cap the add and search paths already enforce (|coord| < 1e16) and fromdim, because the transform reduces across every coordinate: the divided query is summed into a dot product and the bias is adim-long dot product narrowed back to f32. The floor is thereforedim-aware — about 1.9e-20 at dim 64 and 4.8e-18 at dim 16384 — with|tqplus_shift|capped symmetrically and per-vector scales bounded in both the v6 and v7 loaders. The TQ+ fit is magnitude-invariant andcalibrate_2drejects a degenerate sample long before a corpus could approach this, so no honestly-built index changes behaviour. expected_codebookenforces theMAX_DIMbound its rustdoc claims. It assertedbit_widthand the multiple-of-8 rule but not the cap, and the Lloyd-Max solve is O(dim) — so an out-of-rangedimdid not fail, it ran for minutes.from_bytes/load_from_readernow say why async()file is refused. They read thewrite()format, and a v7 sync container hit the generic "wrong magic" error even thoughload()opens the same file — misleading, since the byte entry points documented parity withload. The parity claim is now scoped towrite()output in both rustdocs anddocs/api.md, and the v7 magic gets a targeted error pointing atload(path). v7 stays unsupported there deliberately: it needs random access, andto_bytes()only emits v6.- Agno:
similarity_thresholdunderDistance.cosinenow means what agno says it means. The raw score was mapped to[0, 1]through the inner-product formula(cos + 1) / 2for both distance modes, so a cosine store kept documents down tocos = 2t - 1— a threshold of 0.9 admitted everything to 0.80. agno defines the cosine score as the raw cosine (normalize_cosine), and pgvector, the only other agno store implementing the knob, enforcescos >= threshold. Cosine now passes the clamped raw cosine through;max_inner_productkeeps(ip + 1) / 2. - Agno: an unsupported
search_typeis rejected at assignment, not only at construction.Knowledge.search(search_type=...)mutates the store's attribute directly before searching and does not consultget_supported_search_types(), so a hybrid or keyword request was silently served vector-only and left the attribute misreporting.search_typeis now a validating property. - Agno:
async_insert/async_upsertno longer block the event loop. With any embedder in its default configuration (enable_batch=False— every shipped agno embedder) the async path fell back to the blocking sync embed on the loop thread, one document at a time. It now gathers the per-document async embeds, asLanceDb.async_insertdoes, and keeps ato_threadhop for embedders with no async path at all. - LlamaIndex: filters now run on the metadata the store returns. Each
node was stored twice — the raw Python mapping for filtering and the
JSON-coerced copy for rebuilding the returned node — so any coercing
type diverged: a tuple filtered as
("a", "b")and came back as["a", "b"], a datetime filtered as a datetime and came back as an ISO string, and becausepersist()re-coerces, the same filter changed answers across a save/reload cycle. The store now keeps and filters the coerced dict, matchingSimpleVectorStore, which is self-consistent and persist-invariant. Where a version coerces nothing — the declared llama-index-core floor rejects a datetime in node metadata outright — both sides keep the raw value and stay consistent. - A synced index no longer holds the whole file in RAM, and a sync no
longer holds its payload twice.
loadcarried the entirefs::readallocation into the blocked cache for the index's lifetime — header reserve, per-block scale and id sections and post-npadding included — becausetruncatedoes not release capacity and the followingresizestayed inside it. Andunit_bytesreceived a codes buffer allocated to exactly its codes, so appending scales and ids grew it, and amortized growth doubled every unit held in the write batch. Measured at dim 3072 with 564 rows: retained heap after load drops from 1.00x the file to 0.22x (the codes, which is what a v6 load holds). At dim 768 with 100k rows a large incremental sync peaks at 0.99x the file instead of 1.95x. - A crash-recovered synced index can no longer resurrect the commit it
rolled back past. A commit generation is not unique over a file's
life: when
loadfalls back, the rejected header stays in its slot and the recovered index's nextsyncwrites that same generation into that same slot. Losing only that header write left the rejected header standing — and its delta verifies against the units the new sync rewrote identically — so the load after a second crash could serve a state that had already been rolled back and abandoned. Such a sync now destroys the rejected header behind its own barrier before any data moves; it is the only sync that runs two barriers, and nothing changes in the steady state. loadandsyncno longer hold the delta twice. The commit digest was computed over a materialized copy of every unit a sync wrote, on top of the write payload and — on the load side — the file image already in memory, so a sync that appended most of an index made the next load peak at over three times the file. The digest is now folded from the bytes where they already live, bit-for-bit identical. Loading a 20 MB file after a large append drops from ~77 MB peak heap to under 30 MB, and is ~25% faster.- Masked search no longer drops allowed vectors on AVX-512 VNNI/VBMI hardware. The nq=1 block-interleave (H54) steps the permute-dot block loop eight blocks at a time, but the mask block-skip still tested only the first block of each group — a group whose head block was fully masked skipped all eight, losing allowed vectors in the other seven and padding short results with heap-prefill slot ids. The skip now clears the whole interleaved group.
- Saving a warm index on vector-major hardware no longer corrupts the file. The fused write path borrowed the blocked cache assuming the stored sequential layout; on dotprod ARM and AVX-512-VBMI x86 the cache is vector-major, so saves persisted kernel-layout bytes that reloaded as garbage. The layout guard now lives inside the borrow helper itself, and vector-major caches take the repacking path.
- Single-threaded batch search no longer drops queries 8 and 9 on 2/3-bit vector-major indexes. The thread-aware batch width widened to a 10-query batch wherever that saved a pass, but only the permute-dot (4-bit) kernel carries 10 query lanes; the VNNI kernel that scores 2/3-bit vector-major indexes is 8-wide, so it scored lanes 0..8 and returned the last two queries of every batch empty. The wide width is now selected only when the permute-dot kernel is the one taking the batch, and the VNNI kernel asserts its 8-lane bound.
- Batch search no longer panics (or drops queries) on x86 CPUs without the wide kernels. The 8-query batch introduced for the AVX-512 permute-dot kernels reached the classic 4-slot AVX2/BW kernels whole; those arms now consume it in padded 4-query chunks.
- The NEON tiling A/B env hooks (TV_NEON_MULT/TV_NEON_CAP) are gone — the swept constants are compiled in — and the v6 fast loader no longer forms a mutable slice over uninitialized memory.
Changed
-
A built index now holds one code layout in RAM instead of two (#475). The encoder writes the bit-plane (mutation) layout and the first search derived the SIMD-blocked (search) layout from it; nothing ever freed the first, so an index built in-process carried both for its lifetime while an index loaded from disk had always lived on the blocked layout alone.
addnow builds the blocked layout at its commit point — work search,saveandprepareall had to do anyway, only moved earlier — and drops the packed rows, converging a built index onto exactly the blocked-only state the load path has always used. Measured at 100k x 768d 4-bit: 136.9 MB retained after build-then-search becomes 69.3 MB, a 49% reduction, and the first search stops paying a repack (78.9 ms to 0.5 ms). Steady-state search throughput is unchanged. The trade is that the repack is no longer skippable: a build-then-write()flow that never searches now pays it, worth about +8-12% on total one-time build cost.packed_codes()andcalibraterebuild the packed rows on demand, and subsequent adds take the existing lazy-append path straight into the blocked layout.packed_ready()changes observably. It reports which layout is materialized, so dropping the packed rows makes itfalseafter anyadd:new()→true,add→false,packed_codes()→true,add→false. Two properties it had before are gone — it no longer only goesfalse→true, andfalseno longer identifies a v6-loaded index, since a built index now reaches the same state. No in-tree consumer gates behaviour on it (the Python binding dropped its probes in #392), and it was never a "has this been loaded" probe — but it is public API, and the docs on it, onIdMapIndex::slots_readyand onIdMapIndex::prepareare updated to match. -
VALIDATE_CHUNKis exported as#[doc(hidden)]so its test derives the chunk size instead of copying it (#463). The input-validation reporting test needs an input that genuinely spans more than one validation chunk, and asserts that it does. With a local copy of the threshold that premise assertion was vacuous — derived from the copy it held for any value, so retuning the real constant upward would quietly reduce the test to the single-chunk case it exists to look past. Same reasonRECON_TABLE_MIN_ROWSis exported (#410). Not public API: a parallelism threshold with no format meaning, free to change. -
2-bit search is faster on both architectures. Five changes to the 2-bit kernels and their scheduling: a prefetch on the x86 single-query scan (depth 8, gated so the batched path emits no branch); a 512-bit epilogue for the VNNI kernel, which declared
avx512bwbut still split its accumulator pairs into four__m256; a doubled NEON tile floor at 2-bit geometry, where the floor tracks range bytes and those halved; a two-block interleave on the x86 single-query scan, so the core has more than one miss chain in flight; and, on aarch64 at geometries that fit a single accumulator batch, hoisting the float accumulators out of a loop that never flushes mid-scan. Harmonic mean 1.0495x over eight cells ({arm, x86} x {ST, MT} x {nq=1, nq=100}, 200k x 768, k=10) — largest on x86 single-query at 1.26x. AgainstIndexPQFastScanat the published geometries this reads 1.05-1.32x, up from 1.05-1.27x.Scores are bit-identical. Parity digests are unchanged on both architectures and both bit widths, so recall, returned ids and tie-break order are all exactly as before. No format change; existing index files are unaffected.
-
x86 with AVX-512 VBMI and VNNI scores batch searches with a dot-product kernel. Codes are permuted at load into a layout where each aligned 4-byte group holds one vector's codes for four consecutive byte-groups, so
vpdpbusdreduces them into that vector's own accumulator lane andvpermbselects the right sub-table per byte position. 1.233x on the batch search cell (200k×768 4-bit, nq=100, k=10), holding across 50k–500k vectors, 384–1536 dimensions and 2-bit codes. No format change: this replaces the existing load-time permutation rather than adding one, and existing index files are unaffected. CPUs without both features, and geometries whose byte-group count is not a multiple of 4, keep the previous kernel.Scores change in the last few bits. Accumulation is now exact in u32 where the previous kernel rounded through f32 every 256 byte-groups, so this path is strictly more accurate — but it is not bit-identical to earlier releases, and vectors separated by less than ~5e-05 in score may swap order. Recall is unchanged (measured identical at k=10, with the same returned ids), and results remain fully deterministic: the same query on the same index always returns the same answer. Set
TURBOVEC_NO_VNNI=1to force the previous kernel. -
Batch search schedules its block-axis tiles at a finer grain. Three scheduler changes, results bit-identical by construction (the cross-range merge is a strict total order; verified across nq ∈ {1,4,25,100,257} × k ∈ {1,10,100} plus masked and tied-score shapes on both architectures): the tile target per worker rises 4 → 32 so the final rayon wave amortizes stragglers (nq=100, 200k×768 4-bit: x1.105 ARM / x1.030 x86); tiles are emitted block-range-major so same-range tiles share cache residency (x1.019 ARM); and the NEON dispatch carries its own, 2× finer pair of tile constants where the AVX-512 dispatch keeps the coarser one — the two peak in different places (x1.017 ARM, x86 untouched by construction). Shapes where the block or k caps already bound the range count are unchanged; between nq≈21 and 64 the range count can rise to the block cap.
-
The x86 batch kernel widens its query batch when that saves a pass. A batch width of 10 buys fewer passes over the code array single-threaded but pays more live state per tile multi-threaded, so one constant cannot be right for both: the width is now chosen per search — 10 when running single-threaded, the batch is bound for the 10-lane permute-dot kernel, and the wider batch actually removes a pass at this query count, 8 otherwise (+8.4% at nq=100 single-threaded, +0.60% on the 8-cell mean, and no change multi-threaded or at query counts where both widths need the same passes). The batch epilogue also reduces each block's accumulators at 512 bits instead of 256 (+1.41% on the 8-cell mean); its floats combine in a different order, so scores can move in the last bits, within the tolerance the dot-product kernel already documents above. Both changes soak-tested against a control build from the same tree with identical returned ids and recall.
-
writeandloadare faster on both architectures. No format change, no API change, and the durability protocol is untouched — a save is still a temp file, an fsync, an atomic rename and a parent-directory fsync, andto_bytesstill equals the byteswriteputs in the file.-
Saves on aarch64 (and every non-x86 target) now go through the same parallel positioned writer x86 has used, instead of streaming the whole payload through one
BufWriter: ~3% off a 77 MB save. -
Loading a
.tvimdecodes its id table once instead of four times, reads its tail into uninitialized rather than zeroed memory, and widens the x86 nibble interleave to AVX2. -
The parallel read now chooses its chunking by whether a layout transform is fused into it — an even split when chunk costs are uniform, smaller work-stealing chunks when they are not — which is worth ~15% of a 77 MB load on aarch64 and ~8% on x86.
-
The id table decode and its duplicate-check sort now run on the loader's tail thread, inside the window the codes read already occupies, instead of serially after it.
Together, loading a 200k x 768 4-bit index measures ~1.22x faster on a c4a-standard-8 and ~1.21x on a c3-standard-8. Saving is unchanged on x86, where it was already within 0.3% of the device's own write+fsync+rename floor.
-
-
TQ+ calibration is explicit: the index never fits one on its own. The automatic fit — warm-up buffering, the 1000-row threshold, and fit-from-first-batch — is removed. A calibration comes from exactly one place, the new
calibrate/calibrate_2dmethods on both index types, fitted from a caller-supplied sample (~1024 random, representative rows is enough; the sample's quality is the caller's responsibility). An index that is never calibrated is plain TurboQuant with no fitted state anywhere, and its encoded bytes are independent of batching and insertion order.CalibrationStatecollapses toUncalibrated/Calibrated.calibratemay be called at any time, including on a populated index: the stored rows are re-encoded from their codes under the new pair, no float32 originals needed. Measured costs (pinned as tests): a same-pair refit is bit-identical; calibrating after a large uncalibrated ingest costs ~6–8 pp R@10 versus calibrating first; a badly biased earlier calibration is not repairable by refit (its clipping destroyed the information at encode time) — rebuild from source for that.Migration: a single bulk
addused to fit from the whole batch automatically. That workload now needs onecalibratecall before theaddto keep the TQ+ gain (~2.5 pp R@10 on average, up to ~8.7 measured); the fitted pair — and the encoded bytes — are identical to what the old auto-fit produced from the same rows, which is pinned by the unchanged encode fingerprint. Without acalibratecall the index is uncalibrated: fully functional, order-independent, no TQ+ gain. The warm-up serialization warning and itsRuntimeWarningare gone — the calibration state now round-trips exactly in every case.
Added
-
IdMapIndex::batch_addable(ids). Answers, without mutating anything, whether a whole batch of external ids could be added: no duplicate within the batch, and none already in the index — the pair of preconditionsadd_with_idsvalidates up front. For callers that must establish a batch is addable before adding any of it, so that a rejected batch commits nothing. One short-circuiting pass. -
Incremental saves:
sync(path)on both index types (#475, #476). A saved index is now updatable on disk for the cost of what changed, not the cost of what it holds. The first sync of a fresh path writes the whole file; every later sync to the same path writes only the delta — appended 32-row blocks land past the committed region, a removal rides the commit header as a redo op (an absolute write, materialized into the block by a later sync), and a small alternating commit header (holding the partial tail block) flips last. Every sync is one write batch and ONE fsync: the header names the blocks its sync wrote and carries their bytes' checksum, so a commit that persists before its data is detected at load and the previous commit wins — the journal-checksum trick that replaces write-ordering barriers. Net-zero churn leaves the file size flat; onlycalibrate, a mass removal (>1024 distinct slots pending), a failed sync (recovery re-establishes ground truth), or syncing over a foreign file rewrites it whole.The crash contract, pinned by an exhaustive in-crate harness: a crash at any byte of any write of a sync recovers the previous commit exactly — never garbage, never a blend. A torn commit header fails its checksum and load falls back to the alternate header slot; damage from outside the writer (bit rot, mangled copies) is out of scope, exactly as it is for
write. Every sync is durable — one fsync,write(durable=True)'s strength on every platform — including the temp-file protocol and parent-directory fsync on the full-write path.loadrecognises synced files and lands in the same blocked-only state a.tv/.tvimload reaches (no extra RAM; 0.38 ms vs 0.24 ms for a 50k x 512d load, the delta being the one placement copy the block-interleaved layout needs to make the codes contiguous). A loaded index keeps syncing forward incrementally, ids agree byte-for-byte onIdMapIndex, andwrite/loadkeep their meaning — migrating a.tvfile isload(path)+sync(path). New:synconTurboQuantIndexandIdMapIndex— always durable; when it returns, the commit is on stable storage. -
Self-describing
IdMapIndexsearch results (#351). NewIdSearchResults { scores, ids, nq, k }— the id-space counterpart ofSearchResults, with the samescores_for_query/ids_for_queryrow accessors — returned by newIdMapIndex::try_searchandtry_search_with_allowlist. The existingsearch/search_with_allowliststill return(Vec<f32>, Vec<u64>)and are unchanged; they now delegate to the new forms. The tuple carries no row count and no stride, andkis clamped tomin(k, len, allowlist size), so a 3-vector index queried withk = 10hands back rows of 3 with nothing saying so and the obvious&ids[qi * 10..]reads the wrong row. AlsoIdMapIndex::iter_ids, which enumerates the live external ids in slot order. -
TurboQuantIndex::serialized_len()(#409). The exact number of bytesto_bytes()returns andwriteputs in the file, from the index's geometry alone — no serialization, no allocation. Exact, not an upper bound, for sizing a buffer, a database column or a quota check before paying for the bytes.to_bytesuses it to allocate its buffer once. -
search::blocks_skipped_by_mask()now returnsOption<u64>(#368). Counting mask-skipped blocks costs an atomic RMW per skipped block on a shared cache line, so it is compiled out unless the new off-by-defaultmask-skip-counterfeature is enabled (#294). Previously the accessor returned a plain0in that case, which a telemetry consumer cannot distinguish from "no blocks were skipped" — two different facts sharing one representation.Nonenow means "this build does not count".BLOCKS_SKIPPED_BY_MASKitself is no longer public for the same reason: reading the static directly reproduces the ambiguity theOptionexists to remove. Migration: match on theOption; enablemask-skip-counterif you want the numbers. -
New off-by-default cargo feature
mask-skip-counter— see above. -
TurboQuantIndex::try_searchandTurboQuantIndex::try_search_with_maskreturnResult<SearchResults, SearchError>(#351). The search path had no non-panicking form: a query buffer whose length is not a multiple ofdim, a non-finite or>= 1e16coordinate, or a mask sized for a different index each aborted the calling thread. All three arrive from outside the process in a real service, and the Python binding already pre-validated exactly these three and raisedValueError, so Rust callers were the only ones without a recoverable error.search/search_with_maskare not deprecated, and their signatures, results and validation order are unchanged — they now delegate to the checked forms and panic with the error'sDisplay. Their panic text did change at three of the four sites (four sites, three conditions — the mask-length check has one site for an empty index and one for a populated one). Those three were raised byassert_eq!, so the payload carried anassertion `left == rightprefix plusleft:/right:lines; it is now the error message alone. The fourth, the non-finite-coordinate panic, is byte-identical — it was always apanic!. At the two mask sites the message text was already inside the old payload, so ashould_panic(expected = "mask length")still matches; the ragged-buffer assert carried no message at all, so its old payload and its new one (query buffer length 65 not a multiple of dim 64) share nothing but the two numbers, and anyexpected =string that matched the old one will not match the new. Reach fortry_searchwhen the query vectors are untrusted; keepsearchwhen a malformed query would be a bug in your own code. -
turbovec::expected_codebookandturbovec::MIN_INPUT_NORMare public.expected_codebookgives callers of the rawio::*writers the codebook arrays a v6 file must embed;MIN_INPUT_NORMdocuments the norm at or below which a vector has no representable direction and is stored with scale 0 (#286). -
turbovec::set_warning_hook(andturbovec::WarningHook) route the library's non-fatal diagnostics (#365, #390).set_warning_hook(Some(f))sends them tof— forward them intolog,tracing, or whatever the embedder actually uses — andset_warning_hook(Some(|_| {}))silences them.Nonerestores the stderr default. There is one such diagnostic today: the post-commit durability shortfall from #365. -
v6 loads reject a file whose embedded codebook is not a valid Lloyd-Max codebook for its
(bit_width, dim)(#320). A degenerate codebook — collapsed or reversed centroids — previously loaded clean and silently mis-scored every query. New rejection class for anyone hand-writing files through the rawio::*writers. -
Optional fast-durability writes (#274).
writestays fully durable by default (temp file, fsync, atomic rename, and now a parent-directory fsync so the rename itself is on stable storage — closing a gap between the documented power-loss guarantee and the implementation). NewTurboQuantIndex::write_with_durability/IdMapIndex::write_with_durabilitytake anio::Durability:Fastkeeps the temp-file + atomic-rename protocol — the destination can never hold a torn index and the previous file survives a process crash — but skips fsync (not power-loss-safe; documented). Byte-identical output either way. Measured on the 200k × 768 4-bit reference workload: x86 386 → 286 ms, ARM 191 → 119 ms. -
In-memory serialization:
to_bytes/from_byteson both index types, and genericRead/WriteI/O entry points.TurboQuantIndex::to_bytes/IdMapIndex::to_bytesserialize an index to its.tv/.tvimwire format in memory — byte-identical to the filewrite(path)produces — andfrom_bytesmirrorsloadwith exactly the same validation (version handling, structural and value-level checks, the.tvimduplicate-id check), so bytes and the file they came from load, or fail, identically.write_to_writer<W: Write>/load_from_reader<R: Read>are the generic-sink forms; theiomodule gains the matching raw entry pointsio::write_to,io::load_from,io::write_id_map_toandio::load_id_map_from.IdMapIndexnow derivesDebug. This delivers the in-memory I/O half of #70 (thefrom_partshalf landed in #204) and is the substrate for the Python stores' pickle support. (#148, #149, #70) -
Public items added since 0.9.0 that no entry above announces (#344). Each is
puband reachable from a downstream crate, so listing them is the difference between a documented surface and one a reader has to diff for:io::CodePayload— the tagged code-bytes type theio::load*readers now return in place ofVec<u8>; see the reader signature change under Changed.TurboQuantIndex::packed_readyandIdMapIndex::packed_ready— whether the packed bit-plane rows are materialized. After a v6 load they are not, and no mutation materializes them, so this is how a caller tells a load-seeded index from a freshly-built one.search::single_query_parallelizesandsearch::SINGLE_QUERY_PARALLEL_MIN_BLOCKS— the size half of the single-query parallel gate. The threshold entry under Changed describes moving the constant but never says it became public.TurboQuantIndex::add_parallelizesandturbovec::validation_parallelizes— whether anaddofn_rows, or input validation overlenvalues, injects rayon work that is not proportional to the row count. Bindings that must control which pool that work lands in gate on these (#288, #364).rotation::Rotation(withnew,dim,apply,apply_with_scratch,apply_scaled_into) androtation::K— the block-Hadamard rotation itself, which replaced the removedmake_rotation_matrixbelow.apply_scaled_intoappears above only in a test-hardening note, never as new API.
Changed
-
syncis substantially faster, most of all after removals (#481). Every sync opened by re-reading every block unit the previous commit had written and recomputing its checksum, to decide whether the file was still the one this index last wrote. That commit was already proven — either by thesync_allthat returned success for it, or by theloadthat adopted it — so a commit at the cursor's own generation is now accepted without the re-read. The same identity check also read both commit headers in full; a header slot is sized for its maximum pending-op capacity (hundreds of kilobytes), while the steady state uses a few, so only the used prefix is read now and the rest only when a header actually carries that many ops.On x86 a removal also no longer re-derives the row it moved. Filling a hole already computes the incoming row's stored bytes and was discarding them, leaving the next sync to read them back out of the 32-row block they are interleaved into; they are now kept. This is x86-only by measurement, not caution — off x86 the move is a plain byte copy, so keeping the bytes costs more in
removethan it saves insync.Measured on 200k rows at dim 768, 4-bit — the sync committing 1000 scattered removals went from 18.6 ms to 3.4 ms on x86 and 9.8 ms to 3.5 ms on ARM; the sync committing a 32-row append went from 1.8 ms to 1.7 ms on x86. Nothing about the format, the durability contract or the crash behaviour changes: still one write batch and one
sync_allper sync, and a sync torn at any byte still recovers the previous commit. -
statrsis now an exact version requirement,=0.17.1(#346). It was the caret range"0.17", so any 0.17.x patch release was picked up automatically by a downstream build with no lockfile.statrsis not an ordinary dependency here:Beta::inverse_cdfsets the TQ+ calibration (tqplus_shift/tqplus_scale), which is written into the file and multiplies every coordinate before coding.Betadoes not overrideContinuousCDF::inverse_cdfin 0.17.1, so it gets the trait default — a fixed 16-step bisection on[-2, 2]— and an upstream patch that specialises it, an ordinary improvement to make, would change encoded bytes. Measured: perturbing bothinverse_cdfresults by 3.05e-5 moves the calibration, codes, scales and file hashes of all sixencode_fingerprintcells.rand_chachais pinned for the same reason; this closes the matching hole.0.17.1is what the lockfile already resolved and the newest 0.17.x published, so no build changes version. -
The #383 below-the-table add gate is pinned structurally, not by wall clock (#409, #420).
deferred_adds_below_the_table_do_not_scale_with_nnow asserts that the load-time sorted table is byte-identical after the adds and that the deferred set grew by exactly the rows added — the mechanical statement of "a below-the-table add does not rewrite the table". The old form divided per-add time at 200k vectors by per-add time at 25k and required the ratio under 3. That passed on main for arithmetic rather than for the property: the n-dependent part of a deferred add is only ~2 ps per vector (~400 ns at n = 200k), and it was being divided by a ~3000 ns constant, so it read as 1.1x. Removing that constant (above) left the same slope on a ~350 ns base and the gate failed at 4.5x on CI while the path had become several times faster at every size measured. A ratio cannot outlive its own denominator; the replacement is machine-independent and fails in microseconds. It pins both halves of the property: the write side (the table is not rewritten) and the read side (the presence check stays a binary search, asserted by counting comparisons — a linear scan there is O(n) per add while leaving every structural assertion intact). -
to_bytessizes its buffer up front (#409). It allocatesserialized_len()bytes once instead of growing from empty, so peak live memory while serializing is the payload rather than roughly three times it, and the returnedVechas no spare capacity. On every architecture except x86-64, a warm search cache is written straight through: its bytes are already the sequential layout the format persists, so no intermediate copy is made. x86-64 still materializes one — the native cache is nibble-interleaved there and the de-interleave needs a positioned sink to stream, which a bareio::Writeis not; the file writer, which has one, already streams it chunk-wise. -
Building the SIMD-blocked layout allocates a fixed number of buffers, independent of index length (#409). The packed→blocked extraction step materialises one flat
n_vectors * n_byte_groupsbuffer with a row stride instead of aVec<Vec<u8>>(one heap allocation per vector plus the outer pointer vector), and the 4 KB per-bit-width extraction table is built once per process rather than on every call. Warming a 4096-vector index makes 11 allocations where it previously made 4107; the saving is proportional to length, and it is paid in full by the single-rowaddon a lazily-loaded index, which extracts one row per call. Byte output is unchanged on every architecture. -
A single query enters the fork-safe rayon pool only from 32768 vectors, not 8192 (#336).
search::SINGLE_QUERY_PARALLEL_MIN_BLOCKSwent from 256 to 1024 blocks — one fullMIN_TILE_BLOCKStile, which is the granularity at which the batch dispatch itself splits the block axis. At 256 the gate fired four tile-widths early: the poolinstallhandoff was larger than the entire scan it was paying for, producing an undocumented latency cliff at exactly n = 8192 where a 0.4% larger index made an nq=1 search several times slower. Measured A/B interleaved (14-core arm64, dim=128, k=10, nq=1, inline vs pooled): 0.64x at n=8192, 0.77x at 16384, 0.98x at 32768, 1.34x at 65536 — inline wins up to the new threshold and loses above it. AtRAYON_NUM_THREADS=1inline is never slower at any size. Results are unchanged: both dispatch paths merge in the same (score desc, index asc) order, which the existing cross-path equality tests pin. Callers who read the constant to size a benchmark or a test index will need to re-derive from it rather than hard-code 8192; the in-tree tests now do exactly that. -
The block-axis tile count is one shared function across both architectures.
MIN_TILE_BLOCKSis hoisted out of the two dispatch bodies and the range count comes from a singlen_block_ranges, which clamps annq == 1search thatsingle_query_parallelizesreports as serial to exactly one range. That clamp is what makes the threshold safe to move at all: without it, raising the gate past the tile granularity would split the block axis on a call the Python bindings had already decided to run outside the fork-safe pool (the #147 invariant). -
Encoded bytes now have an absolute golden anchor, not just cross-platform agreement (#352, #346). Determinism was previously checked only by the
Encode fingerprint agrees across OSesCI leg, which compares three operating systems inside a single locked build — structurally blind to any change that moves every platform together.tests/encode_fingerprint.rsfreezes all six fingerprint columns (boundaries, centroids, calibration, codes, scales, file) for the six(dim, bit_width)cells, so astatrsbump, a libm change or a retuned reduction order fails loudly instead of silently re-encoding every future index. The fixture and hashing moved totests/common/fingerprint.rs, shared withexamples/encode_hash, so the anchor and the cross-OS leg cannot drift apart. The two batch-size thresholds that decide encoded bytes are pinned alongside it:RECON_TABLE_MIN_ROWSmust not change them andTQPLUS_MIN_SAMPLESmust change them at exactly 1000 rows. Only an affirmativeTURBOVEC_REFREEZEvalue re-freezes — empty,0,false,noandoffcompare as usual, so a stray environment variable cannot turn the anchor into a silent no-op. No behaviour change. -
The quantize kernels' f64 reconstruction table is built by a named
build_recon_tableinstead of an inline closure (#369). Purely so the kernel identity test can call the production builder; a test that rebuilt the table itself could not see a divergence between the builder and the kernels' inline expression. Its entries are held to the kernels' inline expression at f64 precision, bit for bit, so a reassociation that the f32 packed bytes would round away is still caught.RECON_TABLE_MIN_ROWSis now a named constant next toKERNEL_USES_RECON_TABLE, pinned so raising it fails the build rather than quietly narrowing the threshold test. Same table, same bytes. -
Rotation::apply_scaled_into— the entry point that produces every encoded byte — has direct tests (#372), and the recon-table/inline paths are compared against each other rather than only each against the scalar reference (#369). Both were previously asserted only in doc comments. Test-only; no behaviour change. -
IdMapIndex::removeupdates its tables only after the inner removal returns (#380). Ordering hardening rather than a fix for reachable misbehaviour: no unwind is reachable fromremove, whose slot comes from the id table and so is in bounds by construction — the documentedidx >= n_vectorspanic inTurboQuantIndex::swap_removecannot fire for it. Past that assert,swap_removecallspacked_mut()only when the packed rows are already materialized, so the lazy O(n·dim) rebuild never fires from a remove, and the rest is in-bounds indexing and allocation-free lane ops. Taking the id out ofid_to_slotbefore that call was nonetheless the wrong order: were the inner removal ever to become fallible, a caught panic would leave the id gone from the map, still present inslot_to_id, andslot_to_idone entry longer than the inner index — the vector searchable but unresolvable, with every laterremovecomputing the swap target off the wrong length. The removal now runs first, matching the "index first, then the maps" order the Python stores' delete paths use. No behaviour change. -
x86 search dispatch now tests every CPU feature the kernels declare (#291). The AVX2 gates additionally require FMA and the AVX-512 gates additionally require AVX2+FMA, matching what those kernels execute. On a CPU advertising AVX2 without FMA (reachable via hypervisor CPU models) the previous gates selected a kernel that would SIGILL on first search; such hosts now take the next supported path instead.
-
Masked single-query search is block-parallel (#295). Filtered search previously ran serial over blocks regardless of core count. Measured at n=400k, d=128, 4-bit: an all-true mask went 2.04 → 0.35 ms multi-threaded and 2.04 → 1.11 ms single-threaded. This changes the performance profile of the filtered-search path specifically.
-
IdHashermixes the low bits (#311). Ids that are multiples of 2^32 — the commonshard << 32 | seqlayout — previously collided into one bucket region, making add/lookup/remove quadratic. Measured over 100k such ids: add 1017 → 60 ms, lookup 456 → 0.2 ms, remove 448 → 0.5 ms. Sequential-id removes cost ~5 → ~11 ns each, the price of mixing. -
The GIL is released at more binding sites (#288, #289, #319, #321).
remove/swap_removeprobes, the deferred id-slot map build, and query validation now run detached, so they no longer stall other Python threads while a bulk write holds the lock. -
AVX-512BW paired-block scoring matches NEON in two more geometries (#314). With
n_byte_groups == 1the kernel previously scored the bias alone, and an odd trailing group could be folded into an already-full flush batch, diverging from NEON's rounding. Both were unreachable with current legal dims. -
SearchResultsderivesDebug,CloneandPartialEq(#351). It previously implemented nothing at all, on the type every search returns. A downstream struct holding one could not#[derive(Debug)],dbg!(results)did not compile, results could not be cached or cloned, andassert_eq!in a user's test was unavailable.Eq/Hashare deliberately absent:scoresholdsf32. -
SearchErrorgains three variants and no longer derivesEq(#351).QueryBufferNotMultipleOfDim,InvalidQueryValueandMaskLengthMismatchare what the newtry_searchreturns; the enum is#[non_exhaustive], so adding them is not breaking.Eqgoes becauseInvalidQueryValuecarries anf32— the same reasonAddErrorandFromPartsErrordo not derive it.PartialEqis unchanged and covers every comparison the existing variants supported.SearchErroritself is unreleased (it landed in this same section under #318), so no published version is affected. -
Breaking:
IdMapIndex::search_with_allowlistreturnsResult<(Vec<f32>, Vec<u64>), SearchError>(#318). It previously panicked on an empty allowlist and on an allowlist id missing from the index. Both are input conditions — allowlists are built from the caller's own metadata store, which drifts out of step with the index — so in a service they killed the worker instead of returning an empty page. They are now the newSearchError::AllowlistEmptyandSearchError::UnknownId(u64)(#[non_exhaustive], like the crate's other error enums). The allowlist-freeIdMapIndex::searchis unchanged and still returns the tuple directly. Migration: add?or.unwrap()atsearch_with_allowlistcall sites. The Python binding already raisedValueError/KeyErrorfor both and is unaffected. -
TurboQuantIndex::dim()/IdMapIndex::dim()are deprecated in favour ofdim_opt()(#318). They still returnusize, still return the0sentinel for a lazy index, and still behave exactly as before on a committed index — nothing breaks. The deprecation is the signal:0is only safe for comparisons, but callers do arithmetic with a dim, sobuf.len() / idx.dim()divided by zero andvec![0.0f32; idx.dim()]silently built a zero-length buffer.dim_opt() -> Option<usize>makes the uncommitted case impossible to ignore. -
Stored per-vector scales may differ by ~1 ULP from earlier v5 builds for newly encoded vectors: the scale's f64 reconstruction inner product now accumulates through four fixed chains instead of one serial chain (deterministic, identical across platforms and thread counts; packed codes are unchanged and previously written files load byte-identical). Recall is unaffected.
-
Codebook boundaries are now the f32 midpoints of the f32 centroids, rather than the f64 midpoints cast once to f32 — which makes the whole Lloyd-Max codebook reproducible across platforms, closing the second and last open input in the v5 determinism scope (#259 finding 2). The cross-OS fingerprint CI leg caught this on its first run: Linux, macOS and Windows each produced a different codebook, while calibration, codes and scales were byte-identical on all three. The f64 iteration is not bit-reproducible (
statrs's Beta cdf/pdf bottom out inln/exp, which differ by ~1 ulp between libms, and the adaptive-Simpson recursion can branch differently; at 4 bits the loop also exhaustsmax_iterwithout reachingtol, so the f64 centroids settle only to ~1e-8). Casting a centroid to f32 absorbs all of that — measured invariant under pdf perturbations up to 1e-10 relative — but the midpoint computed in f64 sat a fraction of an f32 ulp from a rounding boundary and flipped under a 1e-15 perturbation at every (bits, dim) cell tested. Averaging the already-rounded f32 centroids removes the knife-edge by construction: f32 add is correctly rounded and* 0.5is exact. Boundaries move by at most 1 ULP versus earlier unreleased builds, so a coordinate sitting exactly on one can change code; both formats are unreleased, so no published index is affected. -
The per-vector norm has one frozen reduction order on every architecture —
c[j % 8] += x*x, combined((c0+c1)+(c2+c3)) + ((c4+c5)+(c6+c7)), with separate multiply and add rather than an FMA. It was previously two different reductions: aarch64 accumulated four chains throughvfmaq_f32(one rounding where the scalar path has two) while everything else summed serially, and those disagree in the last ulp. Since1/||v||rides the first rotation gather, that reached every encoded byte — the remaining cross-platform encode input the v5 determinism scope flagged (#259 finding 1), now closed by construction rather than by observation. Newly encoded vectors can differ from earlier unreleased builds by ~1 ULP in the stored scale, and at an exact boundary tie by one code. Measured recall is unchanged (R@1 and R@4 identical at d1536 2/4-bit and d3072 4-bit; R@16/R@64 move by <3e-4, i.e. a handful of near-ties reordering). Both v5 and v6 are unreleased, so no published index is affected. -
addon a populated index no longer holds allocation-sized intermediates: encode appends in place and reuses a per-index scratch buffer. The buffer is retained at the previous call's demand plus half again, and only shrunk when its capacity exceeds twice that — so repeated, growing and jittering batch sizes keep their warm allocation, while a one-shot bulk load has no previous demand and releases outright. -
MAX_DIMlowered from 65536 to 16384. A loaded.tv/.tvimheader declaring a hugedimdrives allocations (codebook, blocked layout, per-query rotate scratch) not bounded by the file's own size, so the old cap — documented as the bound that "rejects the catastrophic cases" — still permitted a ~16 KB internally-consistent file to demand multi-gigabyte buffers at load or first search. 16384 leaves >4× headroom over the largest embedding dimensions in common use (~4096; rare research models reach 8k–12k). The cap is enforced identically at construction, first add, and load — any index this build can create it can also load back. (#123) -
TurboQuantIndex::from_partsis now a public, validated constructor. Breaking (Rust crate). It waspub(crate)and enforced its invariants withassert!; it is nowpub, returnsResult<Self, FromPartsError>, and checks every structural invariant at this single chokepoint —bit_width ∈ {2,3,4}, a committeddima positive multiple of 8 and≤ MAX_DIM,packed_codes/scales/ TQ+ array lengths (with the implied packed size computed via checked arithmetic, so hugen_vectorsyields a named error rather than an overflow), the lazy-state constraints, and the same value-level checks as the file loader (finite non-negative per-vector scales, finite TQ+ shifts, finite positive TQ+ scales — so an accepted index always survives its ownwrite→loadround-trip) — returning a namedFromPartsErrorinstead of panicking. This is the supported low-level construction path for embedders that hold an index payload in memory (e.g. a database page) and want to skip the.tv/.tvimfile round-trip. The paired accessorspacked_codes(),scales(),tqplus_shift()andtqplus_scale()are likewise promoted frompub(crate)topubso an index round-trips through external storage. New public error typeFromPartsError;TurboQuantIndexnow derivesDebug. (#141, #142; delivers the low-level API requested in #70) -
Save-path performance (#274). Mutations now maintain the SIMD-blocked cache incrementally (only touched blocks recompute), so a mutate-then-save no longer pays the full O(n·dim) repack: post-mutation saves dropped from 1037 → 391 ms (x86) and 495 → 131 ms (ARM), equal to warm saves — also resolving the mutate-then-save item tracked in #273. On x86, path writes use parallel positioned writes for the codes section (small additional win; ARM keeps the streamed writer, where the same technique regresses). Temp files now carry a per-process sequence number so concurrent saves to one path cannot interleave.
-
File format v6 for
.tv/.tvim: the file is the search-ready index — loads skip the first-search rebuild entirely (#68). The code payload is now stored in the arch-neutral sequential blocked layout (32-vector blocks, one code byte per lane) instead of per-vector bit-plane rows, and the file embeds the Lloyd-Max codebook (~124 bytes). A load seeds the search caches directly: non-x86 consumes the stored layout as-is; x86 applies one cheap in-block nibble interleave (a threaded SSSE3 kernel with streaming stores and software prefetch — ~2 ms for a 77 MB payload vs ~400 ms for the bit-plane repack it replaces). Measured cold start (load → first search, 200k × dim 768, Apple M-series): 447 ms → 12 ms. At 2- and 4-bit the code payload is a permutation of the same bytes (file size unchanged apart from padding to whole 32-vector blocks and the ~124-byte codebook); at 3-bit the blocked layout stores one code per nibble, growing the code payload by ~33% versus the packed rows.- One file. The derived state lives inside the index — no sidecar files, nothing extra to ship, copy, or clean up.
- The format adds no platform dependence. The stored layout and embedded codebook are pure functions of the index content: a v6 file loaded and re-saved on a different architecture is byte-identical (verified ARM → x86 through the SIMD interleave kernels), and readers use the writer's codebook instead of recomputing it — removing the cross-libm codebook variance from the search path. (Encoding the same raw vectors on different platforms can still differ per the v5 determinism scope below; v6 neither adds to nor removes that.)
- v5 files load unchanged. v5 stored the same codes in a different layout, so the v6 loader accepts v5 and converts on load (identical search results); re-saving emits v6. Versions ≤ 4 remain refused with the rebuild hint. The writer emits v6 only.
- Mutations never pull the packed rows back. In the window after a
v6 load the blocked cache is authoritative and the packed bit-plane
rows stay unbuilt:
addlazy-appends to the blocked cache andswap_removepatches it with O(dim) lane ops, soTurboQuantIndex::packed_ready()staysfalsefor the index's whole lifetime unless something explicitly asks for the packed rows. A write serializes straight out of the blocked cache. Measured on a dim-64 index:packed_ready=falseafter the load (len 100), stillfalseafter anadd(len 110) and after aswap_remove(len 109), and the file written from that mutated index is byte-identical to one built from the same content from scratch, with identical search results. TurboQuantIndex::codes_blocked_seq/codebook_for_writeexpose the v6 payload parts for embedders serializing through the rawio::*writers (whose code-payload parameter is now the blocked layout).
-
x86 insertion is 1.4-3.5x faster again on top of the pass below (#273). The x86 encode path had a NEON-shaped hole in it: the Walsh-Hadamard butterfly ran as a radix-2 ladder (9 memory passes over the block at dim 1536, where the NEON path had already moved to radix-8), the permutation gather was scalar, the bit-packer OR-ed one bit at a time into a pre-zeroed row, and the reconstruction operand came from a hoisted table that every row streamed in full. All four are now closed, plus an AVX-512 butterfly and the L1-sized calibration transpose below. Every change is bit-identical — packed codes and stored scales are unmoved, enforced against the scalar reference on every SIMD path the host can run. Measured on x86 (Cascade Lake, interleaved A/B, synthetic 1536/3072-dim corpora; the official cells are pending a run on the GCP Sapphire Rapids instance):
cell cold bulk warm append single add d1536 2-bit ST +72% +2.2x -66% d1536 2-bit MT +53% +45% -66% d1536 4-bit ST +91% +3.2x -74% d1536 4-bit MT +67% +2.6x -74% d3072 2-bit ST +61% +2.8x -64% d3072 2-bit MT +42% +2.6x -64% d3072 4-bit ST +75% +3.5x -73% d3072 4-bit MT +63% +3.4x -71% Removal is unchanged:
swap_removewas already an O(1) swap-and-pop and none of this touches it. -
Insertion and removal are substantially faster across a ~35-commit optimization pass (arm d=1536 2-bit: cold bulk add ~4.7x, warm append ~4-6x, single add ~3x, removals ~25% faster; x86 gains larger from a lower base). Encode kernels are now SIMD on both aarch64 (NEON) and x86_64 (AVX2, runtime-detected with a scalar fallback); packed codes are bit-identical across the scalar, NEON, and AVX2 paths, enforced by cross-path identity tests.
-
File format v5 for
.tv/.tvim: a deterministic block-Hadamard rotation, replacing the dense QR rotation (hard break). The coordinate rotation that every quantized code is encoded through is now a globally-permuted block-Hadamard transform at k=2 rounds (ChaCha8- seeded ±1 sign flips → per-block normalized Walsh-Hadamard butterfly → a global Fisher-Yates permutation applied before every Hadamard, twice), applied in place with no matrix and no GEMM. Each round is permute → sign-flip → block-Hadamard; the leading permutation makes the transform order-invariant, so importance-ordered embeddings (matryoshka/MRL, PCA) are handled the same as any other coordinate ordering. The rotation is bit-for-bit deterministic across platforms, CPU architectures, and thread counts (only integer permutations and basic f32 add/sub/scale — no FMA, no reductions, no transcendentals; golden-bytes-pinned) — the property the QR rotation lacked (#206): the old rotation read the global rayon parallelism and usedfaer's order-dependent parallel Householder reduction plus a transcendental sampler, so its output changed withRAYON_NUM_THREADS(dim ≥ 1536) and between libm implementations (dim ≥ 3072), and the rotate GEMM dispatched to a per-OS BLAS backend so the encoded bytes differed by platform. The new transform removes all three causes; recall is neutral versus the QR rotation (measured at dim 768/1000/1536/3072, 2/4-bit, including importance-ordered profiles).Determinism scope: the whole encode pipeline is bit-identical across thread counts on a given machine (verified). Full cross-platform byte identity is not yet claimed: the per-vector norm uses an FMA on aarch64, and the Lloyd-Max codebook is computed at runtime from
statrsBeta cdf/pdf (transcendentals, the same cross-libm class as #206's finding 2) and is not golden-pinned. f64→f32 rounding very likely absorbs both, but a cross-OS byte-hash CI leg (see the recommended follow-up) is what would prove it.- Hard break. The rotation change rewrites every encoded byte, so
v5 is not backward compatible. The writer emits version 5 only; the
loader accepts version 5 only and refuses any version 1–4 index with
a clean, actionable
InvalidDataerror — "format version N … incompatible with the … v5 rotation … rebuild the index" — never a silent mis-decode and never a panic. There is no in-place migration; rebuild from the source vectors. (Format v4 — a rotation-drift fingerprint — was never released; it is superseded by v5. The v5 rotation is deterministic, so no drift fingerprint is needed and the v4 header field is dropped.) - 64-bit
n_vectors. The count field is a u64, so indexes with ≥ 2³² vectors serialize exactly instead of erroring at the v3 u32 ceiling. The in-memory top-k heap index slots widen in lockstep (u32 → u64) so results above slot 2³² − 1 cannot truncate. (#119)
The ChaCha8 seed is frozen and pinned (
rand_chachais depended on at an exact version) with a golden-bytes test guarding the stream, so a future dependency release cannot silently change the wire format. Version-5 files are not readable by earlier turbovec releases: their loaders reject the version byte with a clean "unsupported format version" error (no silent misparse). (#206) - Hard break. The rotation change rewrites every encoded byte, so
v5 is not backward compatible. The writer emits version 5 only; the
loader accepts version 5 only and refuses any version 1–4 index with
a clean, actionable
-
Breaking (Rust crate): the raw
io::*readers return anio::CodePayloadwhere they returnedVec<u8>(#344). The v6 entry above records this for the writers — "whose code-payload parameter is now the blocked layout" — but the readers changed too and were never mentioned.io::loadandio::load_id_mapnow yield(.., CodePayload, ..); at 0.9.0 the same slot wasVec<u8>. Any embedder deserializing through those two entry points fails to compile. The newio::load_from/io::load_id_map_fromreaders added in this release (see above) yieldCodePayloadtoo, but have no 0.9.0 form to break. Migration: match the payload instead of using it directly —CodePayload::Packed(codes)is the oldVec<u8>of per-vector bit-plane rows (v5 files),CodePayload::BlockedSeq { codes, boundaries, centroids }is the v6 sequential blocked layout exactly as stored plus the file's embedded Lloyd-Max codebook, andCodePayload::BlockedNative { .. }is the same codes already transformed into this platform's kernel layout. Callers with no reason to touch the payload should useTurboQuantIndex::load/from_bytes(and theIdMapIndexpair), which take no payload argument and are unaffected.
Removed
-
The OpenBLAS / Accelerate dependency (and
faer,ndarray,rand_distr). The only use of a BLAS backend was the rotation GEMM; the v5 block-Hadamard rotation is applied in place with no matrix multiply, so the native BLAS link, thebuild.rslink-directive shim, and thefaer/ndarray(blas feature) /rand_distrdependencies are all gone. The crate now builds with a plaincargo buildand no native toolchain, which removes most of what took the Linux x86_64 wheel from ~1.8 MB to ~42 MB. (#206) -
The unchecked low-level kernels are no longer public. Breaking (Rust crate).
codebook::codebook,encode::encode,pack::repackandsearch::searchare nowpub(crate). They trust their caller's invariants with no validation, so on the public surface they were a soundness and DoS hazard:search::searchperformed out-of-bounds reads / SIGBUS from inconsistent caller lengths — undefined behaviour reachable from safe code (#141);encode/repackpanicked opaquely on malformed lengths orbits == 0, andcodebookhung on an unbounded2^bitsallocation forbitsin ~32..63 and produced silently-wrong output forbits ≥ 64/ degeneratedim(#142). Migration: construct through the validatedTurboQuantIndex::from_partsor the high-levelTurboQuantIndex/IdMapIndextypes, which establish these invariants for you. Thedump_statedev example, which existed only to dump the now-internalcodebook, was removed with it. (#141, #142) -
Dead
avx2_block_epilogueinsearch.rs(x86-only, ~190 lines, no callers). The live AVX2 epilogue helpers areavx2_batch_flush_to_faandavx2_post_flush_heap_update; the dead copy's logic had drifted from them, so keeping it invited confusion in future kernel edits. No behavior change. (#134) -
rotation::make_rotation_matrix(#344). Breaking (Rust crate). It waspubin thepub mod rotationat 0.9.0 and returned the densedim×dimrotation as aVec<f32>. The v5 block-Hadamard rotation (see Changed) applies its transform in place and never materializes a matrix, so there is nothing left for the function to return. Migration: there is no drop-in replacement, and the substitute is not the same rotation — code that reproduced turbovec's encoding externally must switch transforms rather than translate. Use the publicrotation::Rotation:Rotation::new(dim)thenapply/apply_with_scratch/apply_scaled_into, which is the transform the encoder itself uses.
Fixed
-
TQ+ calibration no longer over-scales heavy-tailed coordinates at 3 and 4 bits (#454). The per-coordinate fit anchored on a hardcoded 5%/95% quantile pair, but the point it is meant to pin — the probability level of the codebook's outermost centroid — moves with bit width (~0.933 at 2 bits, ~0.984 at 3, ~0.996 at 4). The constant was therefore correct only at 2 bits; at 3 and 4 it anchored an interior quantile and stretched the tails far past the codebook's last level, where every value collapses into one bucket. On data with heavy-tailed rotated coordinates this made calibration worse than not calibrating: lastfm-64 at 4 bits scored R@10 0.1439 against 0.4835 with calibration off. The anchor is now derived from the codebook, giving 0.6020 on the same fixture; mainstream embedding datasets move by less than seed noise. Encoded bytes change at every bit width, including 2 — an index written by this version differs byte-for-byte from one written by any earlier version. Existing files still load and search correctly: their calibration is persisted and applied as stored, so only newly fitted calibrations are affected.
-
Serializing a warming-up index that has been drained to zero no longer commits the reloaded copy to identity calibration forever (#418). A sub-threshold
addcommits an explicit non-empty identity(shift, scale)pair for the rows it stores. Removing every one of those rows — the "delete all the documents" sequence the integration stores expose — left that pair committed beside an empty warm-up buffer. In memory the index stayed recoverable, but the payload it wrote carried a full-length identity trailer, sonormalize_calibrationtook its!tqplus_shift.is_empty()early return,warmupcame backNone, and every lateraddof any size sawexisting = Some(identity)and reused it. The reloaded index wasIdentityfor the rest of its life while holding zero vectors, and the existing serialization warning could not flag it because that warning returns early onlen == 0. An exactly-identity pair declares no transform andn_vectors == 0means no rows are encoded under it, so such a payload is indistinguishable from a fresh index; it now normalizes to the same empty pair and warm-up buffer a fresh index has. Reachable throughto_bytes/from_bytes,write/loadand every store'scopy.copy/pickle. No format change — this is only how an already-legal payload is interpreted on load, at the single chokepointfrom_partsand both v6 load arms share, so files written by older versions are recovered too. A drained fitted index is unaffected: its trailer holds a real fit, not identity, so it keeps its calibration on reload exactly as it does in memory (#284). -
rename_atomicretriesERROR_ACCESS_DENIEDas well asERROR_SHARING_VIOLATIONon Windows (#415). The Rust writer had the same too-narrow whitelist as the Python one: a rename onto a destination another writer is concurrently replacing fails with winerror 5 while that destination is delete-pending, not winerror 32, so the retry never fired for it. The two writers implement one protocol against one on-disk format and now recognise the same transient set. -
An empty query batch no longer panics with a divide-by-zero (#349). The batch dispatch splits the block axis into
(n_threads * 4).div_ceil(n_quads)ranges, wheren_quads = nq.div_ceil(QBS)— zero whennq == 0, sosearch(&[], k)aborted the calling thread withattempt to divide by zero. It hit at every index size and on both index types whenever the search ran on a rayon pool with more than one thread; a single-threaded pool returns before the division. The unmasked forms,searchandIdMapIndex::search, hit it on aarch64 and on SIMD-capable x86_64 alike; the masked forms —search_with_mask, andsearch_with_allowlistwhen an allowlist is supplied — only on aarch64, because the x86_64 dispatch marks a masked search serial and so returns before dividing.n_quadsis now clamped to 1 at both batch dispatches. The tile loop is empty atnq == 0either way, so the merge yields the same empty result. An empty batch stays a legal no-op returning an emptySearchResultsrather than becoming aSearchError: it is a routine input — a filter that matched nothing, an empty request page — and it already returned empty results wherever it did not panic. -
The public Rust surface is fully documented, and two more panics have a
# Panicsheading (#324).RUSTFLAGS="-W missing_docs" cargo build -p turbovecreported 55 warnings and now reports 0: theAddErrorandConstructErrorenums themselves, every named field of every struct-variant inAddError/SearchError/FromPartsError, theio::Durabilityvariants, theio::CodePayloadpayload fields, andlen/is_empty/bit_widthon both index types.TurboQuantIndex::swap_remove(panics whenidx >= len()) andIdMapIndex::add_with_ids(panics on a lazy index, where there is no dim to split the buffer by) stated their panic in trailing prose, so rustdoc rendered no Panics section for either. -
search::single_query_parallelizesno longer claims to be "the single source of truth for the gate" (#324). It is the size half, and the whole gate only on aarch64; the x86 dispatch additionally requires runtime AVX2+FMA (or AVX-512) and, without it, runs an nq=1 scan serially at a size the predicate calls parallel. What the predicate really guarantees is one-directional —falsemeans the core never splits the block axis, on every target — and that is the direction the Python bindings' pool routing depends on. The doc now says so. It also says how the predicate is actually reached: neither dispatch calls it directly — each re-tests the constant inline, and nothing makes those inline conditions agree with it — but a single query sent down the batch path meets it again insiden_block_ranges, whosenq == 1clamp pins the block-range count at 1. That clamp is a drift guard, inert whileSINGLE_QUERY_PARALLEL_MIN_BLOCKSandMIN_TILE_BLOCKSare equal (both 1024), since the tile-granularity term already pins the count at 1 on its own. -
Two
no_rundoctests now execute (#324). Theid_mapmodule header and theTurboQuantIndex::from_partsexample touch no filesystem, sono_runbought nothing and theirassert_eq!s never ran.cargo test -p turbovec --docstill runs 4 tests, but only 1 is now compile-only instead of 3 — 3 execute where 1 did, in ~1.1 s. The crate-header example keepsno_run: its point iswrite("index.tv")/load("index.tv"), which would drop a file in the test's working directory. -
IdMapIndexid lookups stay flat for composite ids at every shift width, not just up to 32 (#385). The id hasher's finalizer was a singlez ^ (z >> 32).id = i << szeroes the lowsbits of the Fibonacci product, and fors > 32bits32..sare zero too, so the single fold laid zeroes over the lows - 32bits — exactly the bits hashbrown uses as the bucket index.shard << 48 | seqids therefore landed in one bucket at every table size and the map degraded to a linear scan, the same failure mode #311 repaired fors <= 32. The finalizer now runs two splitmix-style rounds, so the second multiply re-spreads the folded-in entropy before the final fold. Measured on 60ki << 48ids:removewent from ~5.2 µs to ~18 ns each. Hash values change, so iteration order overIdMapIndex's internal maps changes — it was never ordered, and no API exposes it. Encoded bytes, search results and file formats are unaffected. -
IdMapIndex::search_with_allowlistreports every condition its error type declares (#412). The method returnsResult<_, SearchError>, but the two query-shape conditions —QueryBufferNotMultipleOfDimandInvalidQueryValue— escaped as panics from the inner index instead of being returned, even thoughSearchErrorcarries a variant for each. A service that matched on the error and mapped it to a 400 still lost the request thread to a ragged body. Both now arrive asErr, with and without an allowlist. The panicking siblingIdMapIndex::searchis unchanged in behaviour: it re-panics with the error'sDisplay, which is the same message it raised before, and it now carries a# Panicssection naming both conditions. TheSearchErrorvariant table recordsyesfor the pair where it previously readno (panics). -
io::write_toand the other rawwrite*entry points reject abit_widthtoo large to describe a codebook, identically in every build profile (#411).assert_codebook_lengthscomputed1usize << bit_widthunguarded, so at 64 and above debug panickedattempt to shift left with overflow— naming neither the argument nor the function — while release masked the shift to<< 0and carried on with one level, which is satisfiable: a caller passing one centroid and no boundaries gotOk(())and a 26-byte file whose header no reader accepts. One actionable message now covers both profiles. The bound is the shift, not the format's 2..=4: widths below 64 but outside that range still write and are still refused by the load-side header check, unchanged. The# Panicssections on the sixwrite*entry points now state thebit_widthbound alongside the slice-length invariants, so they remain an exhaustive list. -
The reconstruction arithmetic the quantize kernels share with the hoisted table is defined once (#410).
build_recon_tableand the scalar and aarch64 kernels each spelled outcentroid * inv_scale - shiftin f64 separately, pinned only by a test that compared the builder against a hand-copied transcription. That pinned the builder, not the kernels: reassociating a kernel's inline branch left the whole suite green while roughly a third of the reconstructions diverged from the table, because the only cross-path test compares f32 outputs and absorbs a sub-f32 difference. All three now call onerecon_entryhelper, making the bit-identity structural; only the AVX2 packed form stays hand-mirrored, backstopped by the existing avx2-vs-scalar assertion. No encoded byte changes — the encode fingerprint is unmoved, aarch64 machine code is instruction-for-instruction identical, and the x86_64 instruction multiset is unchanged but for two fewerxorps. -
RECON_TABLE_MIN_ROWSis exported as#[doc(hidden)]so its test derives the threshold instead of copying it (#410). The end-to-end test that drives the table/inline switch kept its ownTHRESHOLD = 16, guarded from the crate side by a compile-time assertion. That guard ran one way only — it caught the constant moving out from under the copy, but lowering the copy compiled clean and quietly put both batch depths below the real threshold, leaving the test comparing the inline path against itself. The copy and the guard are both gone. -
An
addthat crosses the 1000-vector threshold fits a real TQ+ calibration even when every earlier row has been removed (#360, #366). A sub-thresholdaddcommits an explicit identity calibration for the rows it stores, andswap_remove-ing all of them leaves that identity committed beside an empty warm-up buffer. The crossing add then had no buffered rows to re-encode, so it took the plain bulk-add path, whereencodesaw a committed calibration and reused it — the index was frozen to identity for the rest of its life, at reduced recall, whilecalibration_state()still reported the recoverableWarmingUp. An empty buffer means no stored rows, so the committed identity describes nothing and is now discarded before the batch is encoded. Draining a fitted index to zero still keeps its calibration (#284) — unchanged. -
TurboQuantIndex::write/to_bytesand theIdMapIndexpair document the warm-up forfeit (#361, #366). The format carries no warm-up buffer, so serializing an index that is stillWarmingUpcommits the reloaded copy toIdentitycalibration for good; the original is unaffected. Only theCalibrationState::Identityenum doc said so, andto_bytesis what a clone-by-round-trip goes through. -
IdMapIndex::prepare()now warms the lazy id → slot map (#348). It only forwarded toinner.prepare(), soid_to_slotstayed unbuilt and the firstsearch_with_allowlist,containsorremoveafter a load still paid the O(n) build the method exists to absorb — measured 2.58 ms for the first allowlist search vs 0.73 ms warm on a 500k index, whileprepare()itself returned in 0.01 ms. Materializing the map also releases the load-timesorted_ids/deferred_addedside-tables, i.e.prepare()now reaches exactly the steady state a first allowlist search would have reached. Still idempotent and O(1) once warm. -
Every raw
io::write*entry point rejects a code or scale buffer that disagrees with the header it is written under (#407).scales.len()must equaln_vectors, andcodes_blocked_seq.len()must be the blocked-layout size(bit_width, dim, n_vectors)implies —n_vectorsrounded up to whole 32-vector blocks timesdim / (8 / bit_width)bytes. These are the two conditionsTurboQuantIndex::from_partsalready returnsPackedCodesLengthMismatch/ScalesLengthMismatchfor, so both entry points to the format now agree on what a valid index is. A violation panics, alongside the existing TQ+-calibration, codebook-length andslot_to_id-length invariants and for the same reason — theio::Resultreports what happened to the sink, not a caller-assembled shape — and it panics before anything is written, so an existing index at the destination is never truncated or replaced. Both sections are sized from the header on load, never from a length prefix, so an inconsistent buffer previously produced not a rejected file but an undefined one: a 16-byte-short codes buffer on a 16×64 4-bit index wrote a file that loaded clean and returned a top score of 1.0073 against unit-norm rows, above the cosine ceiling, and a compensating pair that preserves the total byte count shifts nothing downstream, so no header-derived check can fire on it at all. Writers that take these buffers fromcodes_blocked_seq()/scales()on a real index, and every path throughTurboQuantIndex::write/to_bytes, are unaffected. Widths outside 2..=4 skip the codes check and are still refused by header validation on load (#411). -
The codebook and accepted-codebook memos no longer take a blocking lock on the load path (#390). Both memos added with the load-time codebook validation were
Mutex-guarded and taken withlock().forkclones only the calling thread, so a child inherits every mutex in the state it had at the fork — and one held by a thread the child does not have is never unlocked. Both memos sit on the load path, which is the first thing a forked worker touches, so a fork landing in that window left the child hanging on its firstloadwith no error: the #147/#288/#321/#364 failure mode in a new place. Both are now taken withtry_lock, which cannot block; a lock that cannot be taken is just a memo miss, and a miss is only ever slower, never wrong, because the memoised values are pure functions of(bit_width, dim). The stale-temp sweep'sSWEPTset, the same shape on the save path, istry_lockfor the same reason. Memoisation is unaffected: a repeated load stays at ~90 µs against a ~68 ms Lloyd-Max solve, at one thread and at the default thread count. -
A durability shortfall is no longer written unconditionally to stderr (#365, #390). When a save's post-rename parent-directory fsync fails, the save has already committed and must not be reported as an error — but the shortfall has to stay visible. It was reported with
eprintln!, which a service that captures its logs structurally never sees and no caller can turn off. It now goes through a process-global warning hook (turbovec::set_warning_hook); with no hook installed the default sink is still stderr, so nothing is silently dropped. -
Rust docs: the crate header no longer describes a cache strategy
addabandoned (#324). The docs.rs landing text — the first thing a reader sees — saidadd"extends the packed codes and invalidates the blocked layout cache by replacing itsOnceLock". Neither half is true:addmaintains the blocked cache in place throughget_mut, and after a v6 load it appends into that cache and leaves the packed rows unmaterialized entirely. The replacement states the invariant a reader can actually rely on (every populated cache describes exactly the rows the index holds, whenever the index is reachable through&self) and why it holds by construction, instead of narrating which buffer a particular mutator touches — the detail that went stale. -
Undocumented panics on the public
rotation::Rotation::newand the sixio::write*entry points now have# Panicssections (#324).Rotationis reachable withoutTurboQuantIndex, and itsMAX_DIMceiling was visible only in an implementation comment. The raw writers abort on a length inconsistency among four of their six slice arguments (five of seven for thewrite_id_map*trio), which theirio::Result<()>signature does not suggest; the docs also now say which arguments are not checked —codes_blocked_seqandscalesare written through as given by the writer, and the loader's own length checks do not reliably catch an inconsistent one. A wrong length shifts every later section of the file, so the load may error or may succeed and silently mis-score, depending on what the shifted bytes land on, and which dominates varies sharply with index geometry. A compensating pair that keeps the total byte count unchanged shifts nothing and has loaded clean in every configuration tested. The docs say that rather than promising a failure mode that does not hold; the underlying gap, with the measured sweep, is tracked as #407.TurboQuantIndex::writeandTurboQuantIndex::loadhad no documentation at all despitefrom_bytespointing readers atload;SearchResults::scores_for_query/indices_for_querydocumented their panics in prose without the heading that puts them on docs.rs. No behaviour changed. -
A one-shot bulk
add()no longer pins its rotated-batch scratch for the index's lifetime (#333). The encode scratch only shrank whencapacity > 4 x this call's length— a test the call that grew the buffer can never pass, since growing leaves capacity and length equal. So the batch that allocated the buffer was exactly the one that could not release it, and a copy-pasteindex.add(embeddings)kept a full rotated copy of the batch until the index was dropped. (A later, smaller add did release it; retention was permanent only for the common shape where no smaller add follows.) Retention is now sized from the previous call's demand plus half again, and only applied when capacity exceeds twice that. The slack preserves the amortized growth headroom a growing or jittering batch size relies on, and the hysteresis keeps ordinary shapes from shrinking at all; a one-shot bulk add has no previous demand and so releases outright. There is no retention floor —Vec::reservefrom zero capacity allocates once, so a floor has no allocation cascade to prevent. Measured with a counting global allocator, dim 768 at 2-bit, single thread: a 200k one-shot add retains 623.3 MB before, 37.4 MB after against a 36.6 MB index, and the total allocation count over a run is unchanged to within one — 520 -> 521 for twelve equal 50k adds, 743 -> 744 for twenty adds growing 5% each, 740 -> 741 for twenty jittering between 45k and 55k. Add throughput is unchanged at default threads and atRAYON_NUM_THREADS=1. Note the numbers above are live heap. On macOS this does not show up in RSS at all:psreports the same resident size with and without the fix, for reasons not fully established — the freed spans stay resident even in a sequential build-and-drop loop where they ought to be reused. The allocator-level win is solid; the resident-size win is unverified on any platform. -
Deferred-window adds no longer cost O(n) when the new ids sort below the retained id table (#383). After a load,
IdMapIndexkeeps the load-time sorted id table alive so post-load adds can validate new ids by binary search instead of forcing the O(n)id → slotmap build. The merge that kept it current broke out early only when the new ids all sorted above the table's tail, so an id sorting below rewrote all n entries — per add, under the write lock, quadratic over a chatty post-load pattern. Ids added inside the deferred window now go into a side hash set and the load-time table is never rewritten; a presence check is one binary search plus one hash lookup, and an add costs O(rows added) wherever the new ids sort. Measured at dim=32, 4-bit, interleaved A/B, µs per single-row add with ids below the table: 52.5/102.2/201.2/405.3 → 3.5/3.1/3.2/3.1 at n = 50k/100k/200k/400k (and 51.8/100.2/198.0/393.9 → 3.5/3.7/3.1/3.0 atRAYON_NUM_THREADS=1). Ids sorting above the table were already flat and are unchanged. Note the side set is retained, like the sorted table, until something materializes the map. -
A panicking first add no longer wedges a lazy index at a committed dim (#380).
add_2dlocked the inferred dim before the encode, so a caught encode panic left an index with a dim and no vectors, and the follow-upadd_2dat a different dim gotDimMismatchinstead of the fresh start #129 established. The dim — and the rotation, boundary and centroid caches derived from it — are now rolled back if the add unwinds; rolling back the dim alone would leave the next add at a different dim panicking insiderotationinstead of starting fresh. -
A caught panic in the eager add's cache repack no longer leaves the stored codes ahead of the row count (#388). The blocked-cache patch is fallible, and
packed_codesandscaleswere published before it whilen_vectorswas published after — so a caughtPanicExceptionleft both buffers holding the failed batch's rows against the old count, and the next add addressed past the orphans. That is silent slot corruption rather than a detectable inconsistency. Reordering alone is not a fix: both buffers are taken out of the index before encoding, so deferring their publication makes a panic drop them entirely and leave the index with empty buffers against a non-zero count. The repack now runs under a guard that truncates both back to their pre-call lengths and republishes them, matching the contractencode's own guard keeps. -
The v6 codebook check no longer puts a Lloyd-Max solve on the load path (#357). Validating the embedded codebook by recomputing it and comparing cost 25–100 ms — two orders of magnitude more than the load it guarded. It is replaced by the two properties that define the codebook and cost microseconds: each centroid must equal the Beta conditional mean of its own cell (the Lloyd-Max fixed point, evaluated in closed form), and each boundary must be the exact f32 midpoint of its neighbouring centroids. Rejection strength is unchanged or better — the boundary identity is now bit-exact rather than compared at 1e-4 — and
codebook(bit_width, dim)is memoised process-globally, so repeat builds and saves of the same shape no longer re-solve either. Measured cold load (file → first search, 20,000 × 768 4-bit, interleaved A/B): 66.5 → 1.00 ms at default threads, 66.6 → 0.99 ms atRAYON_NUM_THREADS=1. -
A save that committed is no longer reported as a failure (#365). The rename is the commit point; a parent-directory fsync failing after it left the new file in place while
writereturnedErr, contradicting the documented "the previous file atpathis left untouched" guarantee and sending callers with a rollback policy down a destructive path (the error cleanup also silently no-opped, since the temp name no longer existed). Such a failure is now a durability shortfall, warned about on stderr, and the save reports success. -
Add-path and loader error messages now name the condition that actually occurred (#329). An id repeated inside a single
add_with_idsbatch reported "id N already present in index" — false on an empty index, and it sent callers hunting for a phantom prior insert; it is now the newAddError::DuplicateIdInBatch, "duplicate id N appears more than once in this batch". A zero-width batch (dim == 0, usually an embedder returning empty embeddings) was folded into "vector buffer length 0 not a multiple of dim 0" — mathematically nonsense and the wrong cause — and is now the newAddError::ZeroDimon bothTurboQuantIndex::add_2dandIdMapIndex::add_with_ids_2d. The pre-v5 rejection hint no longer names a version number: it pointed at a release that does not exist and at a remedy the reader was already running. The.tvimwrong-magic error now reads "not a turbovec .tvim file", matching its.tvcounterpart. -
A zero-row
add_2dno longer commits a lazy index's dim (#308).add_2dsetself.dimbefore delegating toadd, whose zero-row no-op guard then returned — so an empty batch permanently locked the dim of a lazy index, changed its serialized bytes (thedim=0sentinel became the batch's dim) and survived save/load, making a later add of the real dimensionality fail withDimMismatch.IdMapIndex::add_with_ids_2dinherited it. A zero-row batch is now a true no-op:dimis still validated (a mismatch against an already-committed dim, or a malformed lazy first dim, reports the same error as before), but nothing is committed and the serialized bytes are byte-identical to a pristine lazy index. Realistic trigger: a lazily constructed framework store whereadd_texts([])or a filtered-to-empty batch preceded the first real batch. -
TQ+ calibration is now a warm-up lifecycle instead of hidden first-add state (#107, #284, #285, #303, #317). An index buffers its raw rows until it has seen 1000 vectors, then fits the calibration and re-encodes those rows with it, in slot order — so a first
addof 1–999 vectors (or a stream of 500-vector batches, the default shape of every framework integration) no longer locks identity calibration and silently forfeits the TQ+ recall gain for the index's whole life. The buffer is bounded by 1000 rows and mirrorsswap_remove. Three further entrances to a mis-declared calibration are closed with it: the commit site now writes only a calibrationencodeactually fitted, so draining an index to empty and re-adding no longer overwrites the fitted calibration with identity (#284); both v6 load arms offrom_loadedroute through the same identity-populationfrom_partsperforms, so a v6 file with an empty TQ+ trailer plus a lateraddno longer produces vectors thatlencounts but search can never return (#303); and the newTurboQuantIndex::calibration_state/IdMapIndex::calibration_stateaccessors make the state queryable instead of invisible (#317). No file-format change: a stored index always declares exactly the calibration its codes were encoded with, and files written by earlier versions load unchanged. -
Declared MSRV corrected from 1.83 to 1.89 — the crate did not build on the version it advertised. The AVX-512 search kernel added in the v6 cycle uses
_mm512_*intrinsics and theavx512f/avx512bwtarget_featuregates, all of which stabilized in Rust 1.89; on 1.83cargo check -p turbovecfails outright withuse of unstable library feature 'stdarch_x86_avx512'(67 errors), so a downstream consumer pinned to the declared MSRV got a hard compile error rather than a scalar fallback. Both packages now declarerust-version = "1.89", verified by a cleancargo +1.89 checkof each plus the full test suite (19 suites) on 1.89. Found byclippy::incompatible_msrvwhile adding the AVX-512 butterfly, which raised the same lint against the pre-existing search kernel. -
Declared MSRV corrected from 1.70 to 1.83. The
rust-version = "1.70"declared in bothCargo.tomls was never accurate: when it was introduced (2026-04-13, chosen for the crate's ownOnceLockuse), the dependency tree already required newer toolchains —pest2.8.6 (transitive viafaer→npyz→py_literal) requires rustc 1.83,faer0.20.2 requires 1.81, and the v4Cargo.lockneeds cargo ≥ 1.78 to parse. Both packages now declarerust-version = "1.83", verified by a cleancargo +1.83 checkof each. (#182) -
Pre-AVX2 x86-64 CPUs no longer SIGILL before the scalar fallback can run. The repo-level
.cargo/config.tomlset a globaltarget-cpu=x86-64-v3(AVX2/FMA/BMI2) baseline, so every plain (non-#[target_feature]) function — including the runtime-dispatch prologue and the pre-AVX2 scalar fallback itself — was compiled with AVX/VEX instructions and faulted on pre-Haswell CPUs beforeis_x86_feature_detected!could ever select the fallback. The baseline is nowx86-64-v2; the AVX2 and AVX-512 kernels stay runtime-dispatched and are#[target_feature]-gated, so they are compiled with their full feature sets regardless of the baseline (re-tuned by the baseline's tuning model). Applies only to builds made from a repo checkout — CI, benchmarks, and the wheel pipeline; the published crates.io.cratedoes not contain.cargo/config.toml, socargo add turbovecusers were never affected. (#137) -
Index saves are atomic.
io::write/io::write_id_map(andTurboQuantIndex::write/IdMapIndex::writeon top of them) now write to a sibling temp file, fsync, and rename over the destination, so a failed or interrupted save no longer destroys a previous good index at the same path; the TQ+ calibration-length assert also runs before any file is created. (#118) -
Saving an index with ≥ 2³² vectors no longer silently wraps.
write_corepreviously truncatedn_vectorswithas u32, producing a corrupt file that loaded clean withn mod 2³²vectors. Resolved by the format-v4 64-bit count field (see Added above), which stores the exact count. (#119) -
Float payloads are value-validated on load. A
.tv/.tvimwith a non-finite or negative per-vector scale, a non-finite TQ+ shift, or a non-positive/non-finite TQ+ scale previously loaded clean and silently poisoned search results (NaN/Inf scores, vanishing or always-winning slots); such files are now rejected withInvalidData. (#122) -
search/prepareon an empty index no longer build the dim×dim rotation matrix. Searching an empty index is now O(1), which also stops a tiny file declaring a largedimwithn_vectors = 0from driving a multi-gigabyte allocation on first search. (#123) -
The published
.cratenow bundles the MIT LICENSE text. TheLICENSEfile lived only at the repo root, outside the package directory, so cargo shipped the SPDXlicense = "MIT"metadata but not the notice itself (MIT requires the notice to accompany copies). A copy of the license is now committed insideturbovec/, which cargo auto-includes in the package. (#166) -
Out-of-distribution vectors no longer explode their correction scale under frozen TQ+ calibration. When a vector added after calibration was frozen reconstructed with a near-zero or negative inner product, the
inner.max(1e-10)clamp turned the stored scale into up to ~1e10 (with a flipped sign for negative inners), letting that one vector falsely dominate every top-k. Reconstructions whose unit-space inner product falls at or below 0.1 are now treated as degenerate and store scale 0, so the vector scores ~0 and ranks last; any stored scale is thereby bounded by 10× the vector's norm. Healthy reconstructions sit well above the threshold (measured minima ≥ 0.56 even at 2-bit dim-8), so healthy vectors encode bit-identically to before on both the SIMD and scalar paths; the zero-vector behavior (scale 0) is unchanged. (#116) -
encode::encoderejects dims that are not a multiple of 8 instead of writing out of bounds. The packed layout allocatesdim / 8bytes per bit-plane, so tail coordinates of a non-multiple-of-8 dim wrote past the end of each row — the top bit-plane panicked with an index-out-of-bounds and lower planes silently corrupted the next plane's bytes. The dead tail branch is removed andencodenow panics up front with a clear message, matching the index-level validation. Unreachable throughTurboQuantIndex, which already validated dim at construction. (#117) -
A failed
add_2dlength check no longer wedges a lazy index.add_2dcommitted the inferred dim before thevectors.len() % dimvalidation panicked, leaving the index with a locked dim and zero vectors — a follow-up add with a different dim saw a confusingDimMismatchinstead of a fresh start. The length check now runs before the dim commit. (IdMapIndex::add_with_ids_2dalready validated before committing and is unchanged.) (#129) -
IdMapIndex::searchrustdoc no longer promises a row stride ofk. The returned(scores, ids)are flattened with a stride ofeffective_k = min(k, len)— e.g. 5 vectors, 2 queries,k = 100returns 10 scores/ids per array, not 200. The doc now states the effective-k slicing formula and how callers recover the stride (scores.len() / nq); a regression test pins the behavior. Doc-only — the return shape itself is unchanged. (#120) -
Added missing rustdoc for
TurboQuantIndex(struct summary) and forSearchResults— itsscores/indices/nq/kfields (row-majornq × klayout, wherekis the effective per-query result count) and thescores_for_query/indices_for_queryaccessors. (#162)
turbovec — Python package (current: 0.8.0 → next: 1.0.0)
Added
-
sync(path)onTurboQuantIndexandIdMapIndex(#475, #476). Incremental persistence: the first sync writes the whole file, later syncs to the same path write only what changed since — kilobytes for a small batch, not the file. A crash at any byte leaves the previous commit intact, and every sync is durable — when it returns, the commit is on stable storage;loadrecognises synced files and a loaded index keeps syncing forward. Re-calibrating makes the next sync rewrite the file once. Runs GIL-released under the write lock. -
Interruptible long search/add (#216). A large batch
search/add/add_with_idsis now processed one row-slice at a time (defaultturbovec.BATCH_CHUNK_SIZE = 4096, overridable per call withchunk_size=), so control returns to Python between slices and a queued Ctrl-C is serviced there instead of at the end of the call. The GIL was already released (#186), but Python delivers signals on the main thread — the one parked inside the Rust kernel — so a Ctrl-C used to be queued until the whole call returned. Measured on a ~7.2 s batch search: the Ctrl-C delay dropped from ~5.4 s (queued to the end) to ~10 ms (within one slice). Pure-Python wrappers over the native kernels — no core change. Chunked results are identical to a single call (eachsearchslice reads one coherent snapshot of the query array, preserving the mid-search-mutation guarantee; eachaddslice is committed atomically). Throughput cost is asymmetric:searchis unaffected (~0 %), but a chunkedadd/add_with_idspays a snapshot, per-slice validation and dispatch, and (add_with_ids) an O(n) pre-existing-id check — measured at roughly 2–7× the unchunked wall time when measured atchunk_size=1000(the base add is fast, so fixed per-slice overhead dominates the ratio; it varies with dim/batch/machine). The shipped default of 4096 slices four times less often and so pays those per-slice costs proportionally less. The absolute overhead is small, on the order of ~1–10 µs/vector. For a throughput-critical one-shot bulk load, passchunk_size=0to run the add whole at full speed. A cancelledaddcommits the completed slices and raises — the index stays consistent and queryable at that count. Two calls stay indivisible and deaf to Ctrl-C by design: a single huge query (nq == 1) and a one-vector add — each is a single kernel call with no slice boundary to return through. Every add chunks, including the first into an empty index. Making those interruptible needs a core cancellation poll (PyErr::CheckSignalsin the hot loops) — the deferred follow-up. -
write(path, durable=False)onTurboQuantIndexandIdMapIndex: keeps atomic-replace semantics but skips fsync (not power-loss-safe) — see the Rust-surface entry for details and measurements (#274). -
Per-store similarity modes for all four integration stores (LangChain, Haystack, LlamaIndex, Agno), fixed at construction and recorded in the persisted side-car. Two modes:
cosine(the default). Document vectors are L2-normalized before they reach the quantized index and query vectors before search, so raw scores are true cosine similarity in[-1, 1]for embeddings of any magnitude, and ranking matches each framework's in-tree reference store (InMemoryVectorStore,InMemoryDocumentStore's cosine branch,SimpleVectorStore). Zero vectors cannot be normalized and are kept as-is — they score0against everything, matching the references.dot_product(explicit opt-in). Raw vectors, raw inner-product scores, magnitude-aware ranking — exactly the previous behavior. Absolute score thresholds are dataset-relative in this mode and need calibrating per embedder.
Parameter surface per store: Haystack's existing native
embedding_similarity_function("cosine"/"dot_product") now selects the mode (and, as before, thescale_scoreformula); Agno's existingdistanceparameter acceptsDistance.cosine(default) and now alsoDistance.max_inner_product(Distance.l2still raises); LangChain and LlamaIndex gain asimilarity: str = "cosine"keyword (a documented turbovec extension — their references compute cosine unconditionally). Unknown mode values raiseValueError. (#114) -
turbovec.__version__. The package now exposes the standard version attribute (resolved lazily via PEP 562 from the installed dist metadata, soimport turbovecstays sub-millisecond — importingimportlib.metadataeagerly would multiply the import time by ~25x). Falls back to"0.0.0.dev0"when no dist metadata is installed. (#153) -
to_bytes()/from_bytes(data)onTurboQuantIndexandIdMapIndex. Serialize an index tobytesin its.tv/.tvimwire format — byte-identical to the filewrite(path)produces — and reconstruct it from those bytes with exactly the same validation asload(corrupt or drifted payloads raiseValueError). Both run with the GIL released;from_bytesacceptsbytesorbytearray. This is the in-memory persistence path (caches, databases, pickling) that previously required a filesystem round-trip. (#148, #70) -
All four integration stores are picklable and copyable. The LangChain, Haystack, LlamaIndex, and Agno stores implement
__getstate__/__setstate__(the Rust index rides along as its.tvimbytes; the per-store lock — and Haystack's async executor — are excluded and recreated on restore),__deepcopy__, and__copy__. The state is snapshotted under the store's writer lock, so a pickle overlapping a write captures a consistent index/side-car pair (per-doc payload dicts are copied deeply enough that an in-place metadata update landing mid-pickle cannot tear the snapshot).pickle.loads(pickle.dumps(store))round-trips documents, metadata, search results, the similarity mode, and the handle counter, including intomultiprocessingspawn workers.copy.copydeliberately equalscopy.deepcopy: there is no meaningful shallow copy of a store — sharing the mutable Rust index means mutations bleed between the copies (see Fixed). (#148, #149) -
TurboQuantIndexandIdMapIndexsupportpickle,copy.copyandcopy.deepcopy(#340). Both classes implement__reduce__, reducing tofrom_bytes(to_bytes())— so a bare index can cross amultiprocessingspawnboundary (the default start method on macOS and Windows) and any user container holding one can be deep-copied. A reconstructed index is fully independent of the original. Pickle inherits theto_bytespersistence contract unchanged, including theRuntimeWarningthat an index below the 1000-vector TQ+ sample threshold reloads committed to identity calibration — checkindex.calibration_statebefore serializing. Previously only the four integration stores implemented the protocol; the bare index raisedTypeError: cannot pickle. -
Both index classes are weakly referenceable (#340). An index can be held in a
weakref.WeakValueDictionary— the standard way to key a per-tenant cache without pinning its memory.weakref.ref(index)previously raisedTypeError.
Changed
-
2-bit search is faster on both architectures.
search()inherits the Rust crate's 2-bit kernel and scheduling work: harmonic mean 1.0495x over eight cells, largest on x86 single-query at 1.26x. Scores are bit-identical, so recall, returned ids and tie-break order are unchanged, and existing index files are unaffected. -
Live-index mutation is substantially faster, with encoded bytes unchanged. Measured at N=200k, dim=768, 4-bit on c4a (arm) and c3 (x86), multi-threaded / at
RAYON_NUM_THREADS=1:operation arm x86 cold bulk insert x1.95 / x1.17 x1.80 / x1.34 warm append x1.87 / x1.16 x2.54 / x1.62 single add_with_idsx2.18 / x1.60 x3.88 / x2.59 removex1.09 / x1.09 x1.02 / x1.05 Three causes, all overhead rather than encoding work — the
to_bytesoutput and search results are bit-identical to before on both architectures.removeno longer releases and reacquires the GIL on every call to probe whether its id→slot map is built (the answer only ever goes false→true, so it is latched). A single-rowadd_with_idsno longer hands off to the rayon pool to encode one row, matching the bypassaddalready had. And the interruptible add wrapper's whole-batch pre-validation — which made anabsarray and a bool array the size of the batch, sorted the id array, and ran a Python-level membership check per id — now runs natively as the core's own predicates. Chunking, atomicity of a rejected batch, and Ctrl-C behaviour are unchanged. -
sync(path)is substantially faster, most of all after removals (#481) — see the Rust entry above for the mechanism. On 200k rows at dim 768, 4-bit, the sync committing 1000 scatteredremovecalls went from 18.6 ms to 3.4 ms on x86 and 9.8 ms to 3.5 ms on ARM; the sync committing a 32-rowadd_with_idswent from 1.8 ms to 1.7 ms on x86. Durability is unchanged:syncstill returns only once the commit is on stable storage. -
calibrate(sample)onTurboQuantIndexandIdMapIndex, and the automatic TQ+ fit is removed — see the Rust entry above for the full contract and migration.calibration_statenow reports"uncalibrated"or"calibrated"; the warm-up serializationRuntimeWarningis gone; the interruptibility wrapper now chunks every add (slicing is always byte-exact, since an add never fits). -
BATCH_CHUNK_SIZEdefault raised from 1000 to 4096. Every add now chunks (the warm-up gate that ran the first bulk add whole — and deaf to Ctrl-C — is gone), so bulk loads pay the per-slice snapshot + pool handoff too. At 4096 rows the between-slice Ctrl-C latency stays in single-digit milliseconds while a 100k x 768d bulk add goes from 0.11 s (at 1000) to ~0.07 s;chunk_size=0opts a call out entirely and is faster than the old unchunked first add (~0.03 s, the core having shed the warm-up bookkeeping). -
llama-indexextra now requiresllama-index-core>=0.12.1, raised from>=0.11(#386). The declared floor was never supported. Until 0.12.1 the field is spelledmetadata_seperator— the upstream typo — andTextNode.metadata_separatordoes not exist, so pydantic silently discards the value at construction: on 0.11.0,TextNode(text='t', metadata_separator='|SEP|').metadata_seperatoris'\n', the default, and reading.metadata_separatorraisesAttributeError. That happens with no vector store in the call path at all, so the full-node fidelityTurboQuantVectorStorepromises could not hold at the advertised floor and nothing in turbovec could bridge it. 0.12.1 is the first release wheremetadata_separatoris a realTextNodefield; the integration suite is green there (95 passed, 3 skipped) and fails at 0.12.0 and below. Two of the three remaining skips are optional filter operators (FilterOperator.TEXT_MATCH_INSENSITIVE,FilterCondition.NOT) that upstream adds in 0.12.6 and that the store already degrades gracefully without — they are not fidelity failures, which is why the floor is 0.12.1 and not 0.12.6. The third,test_failed_persist_preserves_previous_store, is unrelated to the floor choice and is not cleared by 0.12.6 either: below roughly 0.12.40 upstream json-serializes node content eagerly insidenode_to_metadata_dict, soadd()raises before the mid-persist failure that test provokes can be reached. Users pinned below 0.12.1 must upgradellama-index-core; no turbovec API changed. -
LangChain / LlamaIndex / Agno async methods no longer block the event loop, and
asyncio.wait_fornow works on them (#342). Thea*/async_*methods ran their index work inline on the loop thread, so a largeaadd_textsblocked the loop for the operation's full duration and a deadline could never be delivered at all —await asyncio.wait_for(..., timeout=0.05)ran to completion with noTimeoutErrorraised and every document committed. Each method now runs its sync body on a worker thread viaasyncio.to_thread, matchingVectorStore's ownrun_in_executordefaults and theasyncio.to_threadshape Agno's in-tree sync-backed vector DBs use. One offload per method, never one per chunk, so the locked bodies stay atomic and the issue-#146 / #89 orderings are unchanged. Agno'sasync_exists/async_name_exists/async_get_countstill answer inline — O(1) reads where a thread hop costs more than it saves. Cancellation is partial by design: the awaiting caller is released promptly, but cancelling does not decide what happened to the write. A worker that already started runs the call to completion (work inside the Rust core is not interruptible) and the write commits in full; a call still queued behind a saturated executor is cancelled before it ever runs and nothing is written. A cancelled write is therefore "outcome unknown" — it may have fully committed, or may never have begun — so make retries idempotent. The one guarantee is that the outcome is all-or-nothing: the store is never left torn. Documented per integration. -
Index file format break (v5): saved indexes from older versions no longer load. The Python package inherits the Rust crate's format v5 rotation break (see the Rust section): any
.tv/.tvimfile, pickle, orto_bytespayload written by an earlier turbovec — including the framework stores' on-disk state — is refused on load with an actionable "rebuild the index" error rather than silently mis-decoding. Rebuild affected indexes from the source vectors. In exchange, encoded output is now deterministic across platforms and thread counts, and the wheel no longer bundles OpenBLAS. (#206) -
LangChain: non-
strids are now rejected withTypeErrorat the add boundary (add_texts/aadd_texts/add_documents/from_textsand async variants), naming the offending id, its type, and its position, before any embedding-store mutation. Previously an off-contract id (the declared type islist[str]) was accepted in-memory and then corrupted by JSON persistence: anintid2round-tripped as the string"2", and an int id coexisting with its equal-looking str id (2+"2") produced a duplicate JSON key ondumpthatloadcollapsed — one document silently destroyed and the side-car left unloadably out of sync with the index. This is a deliberate safer-than-reference deviation:InMemoryVectorStoreaccepts non-str ids and exhibits the same dump/load corruption.Noneentries in an explicit ids list are still replaced with generated UUIDs;bool(a subclass ofint) is rejected like any other non-str type. (#124) -
Default scoring of the four integration stores changes for non-unit-normalized embeddings. Under the new
cosinedefault the stores normalize documents at add time and queries at search time, so scores and ranking are cosine — previously they were raw inner products (magnitude-aware). For unit-normalized embedders (OpenAI, Cohere, sentence-transformers withnormalize_embeddings=True) the scores are identical up to quantization noise and nothing changes. For non-unit embedders, freshly built stores now rank by angle rather than by‖v‖·‖q‖·cosθ, and score magnitudes move from unbounded to[-1, 1]— this is the fix for #114; opt intodot_productmode to keep the old ranking. Persisted stores are unaffected: a side-car written before the mode field existed holds raw vectors and loads indot_productmode, keeping scoring byte-identical with zero migration (see the schema notes below). (#114) -
Integration side-car schemas record the similarity mode (each bumped per its own versioning convention; every loader still accepts all older versions):
- LangChain
docstore.jsonv1 → v2 (similarityfield; v1 loads asdot_product). - LlamaIndex
nodes.jsonv2 → v3 (similarityfield; v1/v2 load asdot_product). - Agno
docstore.jsonv1 → v2 (distancefield; v1 loads asmax_inner_product, updatingself.distanceto match; a v2 file whose recorded mode conflicts with the constructor'sdistanceraisesValueErroratcreate()because Agno's construct-then-load shape means both sides hold a mode). - Haystack
docstore.jsonv2 → v3 (vectors_normalizedfield; v1/v2 vectors were always written raw whatever their recordedembedding_similarity_function, so they load with normalization off and keep the recorded function for thescale_scoreformula — byte-identical behavior, including the saturating cosine-branchscale_scorethose stores had). New writes into a legacy-loaded store stay raw (mixing normalized and raw rows would corrupt ranking). (#114)
- LangChain
-
All four integration stores are now safe for concurrent multi-threaded use; writes serialize on a per-store lock. The LangChain, Haystack, LlamaIndex, and Agno stores adopt a layered design measured in the #161 research: every mutating method (add/write/insert, delete, upsert, update, clear/drop — sync and async) and every save path (
dump/save_to_disk/persist/save) serializes on a per-storethreading.RLock; reads take no lock, so concurrent searches keep overlapping and scaling across threads (#186). Adds now populate the side-car maps before the index insert (with a failure-unwind preserving the #89 "a failed add never destroys existing data" guarantee) and deletes remove from the index first, so a search can never surface a handle that doesn't resolve; result translation skips handles whose entries a concurrent delete removed mid-search, and filtered searches retry a stale allowlist and fall back to a non-raising post-filtered search under sustained churn. The resulting contract: a read overlapping a write sees pre- or post-write state, and under heavy concurrent churn a search may transiently return fewer thankresults. There is still no cross-call atomicity, and multi-process access remains unsupported. (#161) -
Haystack
write_documentsunderFAIL/NONEnow partial-writes like the reference instead of being atomic. Previously the whole batch was validated up front and aDuplicateDocumentErrorleft the store untouched.InMemoryDocumentStoreinstead commits each document as it iterates and raises on the first duplicate, persisting every preceding non-duplicate document. Per the maintainer ruling on #167, the store now matches that post-exception state exactly: documents are validated and committed one at a time in batch order, so after the raise everything before the first colliding id is persisted (an in-batch repeat keeps its already-committed first instance). Each individual document commit remains all-or-nothing, so a document failing turbovec's own embedding validation mid-batch persists the documents before it and leaves the index and id maps consistent.OVERWRITE/SKIPsemantics and all success-path return counts are unchanged. (#167)
Removed
- Wheels no longer ship a
turbovec.mlxnamespace package (#305). Locally-built wheels picked up stale__pycache__for anmlxsubpackage whose sources no longer exist, soimport turbovec.mlxsucceeded and yielded an empty module. It now raisesImportError.
Fixed
-
Agno:
async_insert,upsertandasync_upsertnow fail before embedding whencreate()was not called (#473). Syncinsert()already refused at that boundary, but the other three embedded the batch first and only discovered the uninitialized store when they delegated into it — so a caller who forgotcreate()still paid for the embedding work (a paid API call, GPU time) on a write that could never succeed. All four now check the same boundary first, with the same error. Empty batches are unchanged and remain a no-op. -
A deletion no longer stalls for seconds while searches are running (#484).
swap_removeandIdMapIndex.removeroute through a GIL-aware write lock that, when contended, waited for the lock detached, immediately dropped it, and retried attached. That threw awayRwLock's queueing fairness:searchholds the read lock for its whole detached duration, so the retry had to win an unsynchronised race against every searcher, and one background searcher was enough to starve a delete. Measured at n=400k, dim=128: eight removals took 3.35 s with a single searcher (worst 3352 ms) and 17.63 s with four (p50 2228 ms) — against 66 ns uncontended — and an earlier 8-searcher probe never returned at all. The helper now takes a closure and performs the removal inside one detached blocking acquire, inheriting the queueing the other write paths (add,prepare,__len__,remove's slow path) have always used. The same probes now take 0.22 s (worst 37 ms) and 0.76 s (p50 101 ms);IdMapIndex.removeunder four searchers goes from 22.93 s (worst 10 280 ms) to 0.29 s (worst 55 ms). The uncontendedtry_writefast path is unchanged and still costs 0.34 us/op. -
Copying or saving a warming-up index that has been drained to zero no longer commits the copy to identity calibration forever (#418). Deleting every document from a store that never reached 1000 vectors and then persisting or copying it —
dump(),persist(),copy.copy(store),pickle— produced an index reportingcalibration_state == "identity"withlen == 0, which no later ingest of any size could ever move off identity. It now comes back"warming_up", so the next corpus gets a real fit. See the Rust crate entry for the mechanism. A drained fitted index still keeps its calibration across the same round trip (#284). -
Concurrent saves to one path no longer intermittently raise
PermissionErroron Windows (#415).atomic_saveretried theos.replacethat publishes each artifact, but only forERROR_SHARING_VIOLATION(winerror 32). Replacing a destination leaves the file it supersedes delete-pending until its last handle closes, and every rename against a delete-pending file fails withERROR_ACCESS_DENIED(winerror 5) instead — so two threads saving to one path raced through a window the retry did not cover, and the save failed withPermissionError(13, 'Access is denied'). Both codes are transient and now retried; permanent failures (a read-only destination, a directory in the way, a missing privilege) still surface on the first attempt. Completes #316, which made concurrent same-path saves non-corrupting but left them able to raise. The temp-file cleanup in the same function is now genuinely best-effort as documented: it swallowed onlyFileNotFoundError, so an antivirus or indexer holding a freshly-written temp could turn a save that had already landed on disk into an error — while never masking the save's own exception. -
A
warningshandler that touches the index it is saving no longer deadlockswrite()(#360). The core's post-commit durability warning (#365) is emitted from insidewrite_with_durability, so it ranwarnings.warn— and through it a user-replaceableshowwarning, alogging.captureWarningshandler orsys.unraisablehook— while the binding still held the index read guard. A handler that calledadd,removeorswap_removeon that same index asked for the write lock from under a live read guard and blocked forever, wedging the pool thread that emitted the warning; the save never returned. The message is now queued while the guard is live and delivered by the saving thread once the guard is gone, so the handler runs with no lock held. Delivery is unchanged otherwise: same text, sameRuntimeWarningcategory, same order relative to the warm-up warning (durability first), and a filter that raises still goes tosys.unraisablehookrather than failing an already-committed save. The warm-up serialization warning had the same defect and was fixed earlier; this was the remaining path. -
search()on an empty query batch no longer raisesPanicException(#349).ix.search(np.zeros((0, dim), np.float32), k)reached a divide-by-zero in the core's block-range tiling (see the Rust entry), which crossed the PyO3 boundary aspyo3_runtime.PanicExceptionrather than any exception a caller would think to catch. It needed the batch to run in the fork-safe pool, and fornq == 0that happens only whensingle_query_parallelizes(len(index))is true —lenrounded up to 32-vector blocks reachingSINGLE_QUERY_PARALLEL_MIN_BLOCKS, then 256, so 8161 vectors and up, matching the bisect in the issue (8160 fine, 8161 panicking). Below that the extension's global rayon pool is pinned to a single sentinel thread and the one-thread path returns before the division.TurboQuantIndex.searchandIdMapIndex.searchnow return their documented(0, effective_k)-shaped arrays at any index size, with or withoutmask=/allowlist=. -
A completed
save()is durable: the integrations fsync the directory the index and side-car were renamed into (#350).os.replacepublishes a name by updating the directory, so fsyncing the two temp files made their contents durable but not the renames that named them. A power loss after a save returned success could leave the directory entry unwritten and the store back at its previous contents. All four stores (LangChain, LlamaIndex, Haystack, agno) share the write path and get the fix together, matching the Rust writer's parent-dir fsync. When the index and side-car live in different directories, both are synced. The fsync is skipped on Windows, which has no directory-fsync equivalent, and a filesystem that refusesfsyncon a directory fd is tolerated rather than turning a completed save into an error. -
A side-car
schema_versionmust be an integer, not merely equal to one (#350). The version gate wasversion not in compat, and==crosses numeric types in Python, so2.0andtruewere accepted as versions 2 and 1 — a side-car from a non-Python writer (JSON has a single number type) passed a gate it does not actually match. A version is an identifier rather than a quantity, so the type must match too. All four stores share onecheck_schema_versionhelper; their error messages and the versions they accept are unchanged, and"2",Noneand unknown integers are rejected exactly as before. -
A
mask=whose bytes are not 0 or 1 now filters by numpy's own truthiness (#349). numpy storesbool_in one byte and does not constrain the value, sonp.array([2], np.uint8).view(bool)hands Python aboolarray numpy reports as truthy. Those bytes were read straight into Rustbool, which may only hold 0 or 1 — undefined behavior, and it mis-filtered concretely: a mask selecting slots{3, 9, 40}returned five results drawn from slots the mask never selected while dropping ones it did. The mask buffer is now read as bytes and compared!= 0, matching numpy. A cleanboolmask is unaffected, and the dtype and C-contiguity errors are unchanged. -
type(index).__module__reportsturbovec._turbovec, notbuiltins(#340). Anything recordingf"{cls.__module__}.{cls.__name__}"— a frameworkto_dict, aspawnpayload, a Sphinx cross-reference — storedbuiltins.TurboQuantIndex, which resolves nowhere, andpickle.dumps(TurboQuantIndex)(the class, not an instance) raisedPicklingError. This also changes the class name inTypeErrormessages and inrepr(type(index)). -
inspect.signature()reports the documentedchunk_sizekwarg onsearch/add/add_with_ids(#340). The chunking wrappers set__wrapped__, whichinspect.signaturefollows by default, so it reported the native signature:chunk_sizewas invisible tohelp()and IDE completion, andSignature.bindrejected it — so every signature-driven caller (pydantic.validate_call, framework tool-arg introspection, CLI adapters) refused a parameter that works at runtime. The wrappers now carry an explicit__signature__(the native parameters plus keyword-onlychunk_size=None) and report the public method's__qualname__/__module__rather than the internal_make_search.<locals>.searchclosure.__wrapped__is still set. -
A float
turbovec.BATCH_CHUNK_SIZEis coerced withint()like an explicitchunk_size=argument (#345). The coercion documented for the slice size applied only to the per-call argument, so assigning a float to the public constant surfaced asTypeError: 'float' object cannot be interpreted as an integerfrom inside the wrapper, on anaddwith nothing wrong with it. -
The warm-up serialization
RuntimeWarningis one-shot per index, not per process (#360, #366). Every index warns the first time it is serialized whilecalibration_stateis"warming_up"and it holds at least one vector, rather than one index per process doing so. What reaches the user is then up to the filter chain: under the default configuration CPython dedupes per(text, category, module, lineno), so tenants holding the same number of vectors and saving from one shared call site still collapse to a single warning — measured, 3 tenants of 10 vectors deliver 1 under default filters, 3 underalways, and 3 under default filters once their counts differ (10/11/12). So the per-tenant gain is real but conditional; the unconditional part is that the library no longer suppresses anything after the first index. A warming-up index drained to zero vectors is serialized silently and its reload is permanently identity — that is #418, and out of scope here. The latch is consumed only oncewarnings.warnhas returned, so asimplefilter("error")save — which raises out of the warn and is routed tosys.unraisablehook— leaves the index able to warn again, andpytest.warnsaround a warming-up save no longer depends on what an earlier test in the session saved. A serialization under anignorefilter still consumes that index's latch:warnreports the same thing whether the chain delivered the warning or dropped it, so there is nothing to branch on (#360). -
The warm-up serialization warning is attributed to the caller's own file (#366). A Rust frame is not a
warningsstack level, so the warning was credited to the nearest Python frame — for every integration store's save path that isturbovec/_persist.py, a turbovec internal the user never wrote, which also keyed__warningregistry__there. It now names the first frame outside theturbovecpackage, i.e. thewrite()/dump()/persist()/copy.copy()call the user made. The core crate's durability warning (#365) now shares that emitter, but its attribution is unchanged: it is raised with no Python frame on the stack, so the walk finds none and falls back to CPython'ssys:1, exactly as before. -
The warm-up warning says "serializing", and mentions copying. It also fires from
to_bytes, which is the pathpickle,copy.copyandcopy.deepcopytake on all four integration stores — so "saving an index" pointed the reader at a save call that does not exist.write,to_bytesand the stores' copy/pickle sections now state that a copy of a store below 1000 vectors is permanently committed to"identity"while the original keeps its warm-up buffer (#366). -
IdMapIndex.prepare()warms the id map and has a docstring (#348). It inherits the Rust-side fix above, so the firstsearch(..., allowlist=),contains()orremove()after a load no longer pays an O(n) build thatprepare()promised to absorb.inspect.getdoc()previously returnedNonefor it whiledocs/api.mdadvertised it as "same asTurboQuantIndex". -
nq=1 searches on indexes of 8192–32767 vectors no longer take the process-wide rayon pool (#336). They ran on the shared pool for work too small to split, which cost the
installhandoff and serialized every concurrent caller behind one queue. Measured atRAYON_NUM_THREADS=1, n=16384, 14 Python threads: 19,078 → 70,865 queries/s (3.71x), with thread scaling going from 1.24x to 4.32x. At default threads and n=32768 the same comparison is 20,001 → 49,007 q/s (2.45x). This does not remove the ceiling reported in #336 for larger indexes: work that genuinely splits still goes through the one process-local pool and is still capped byRAYON_NUM_THREADS, which is inherent to the fork-safe single-pool design (#147/#288/#321/#364). -
A save whose parent-directory fsync fails now raises a
RuntimeWarninginstead of printing to stderr (#365, #390). The save has committed and still succeeds, but the durability shortfall was written straight to stderr by the core crate — unfilterable, and invisible tologging.captureWarnings(True). The extension now points the core's warning hook at Python'swarnings, so it behaves like the warm-up save warning: filterable, capturable, assertable withpytest.warns. -
A one-shot bulk
add()/add_with_ids()no longer pins its GIL-safety snapshot for the index's lifetime (#333). The snapshot buffer carried the same unsatisfiable shrink condition as the core's encode scratch and now follows the same policy — retain the previous call's length plus half again, and only shrink when capacity exceeds twice that. Together with the core fix this drops both copies of a bulk batch that an index used to hold afteradd()returned. As with the core entry, the measured win is in live heap and does not appear in macOS RSS, for reasons not fully established; treat the resident-size effect as unverified. -
The JSON side-car no longer writes data it cannot read back (#350). ⚠️ Breaking for stores holding non-finite floats anywhere in the side-car — see the migration note below. Two payloads passed
json.dumpsbut did not survive the file, silently, across all four integrations' save paths. Non-string metadata keys were stringified, so{1: "int-one", "1": "str-one"}landed on disk as a single{"1": "str-one"}— one entry gone, withsave()returning success (True/1and2020/"2020"collided the same way). NaN and Infinity were emitted as bare tokens RFC 8259 forbids:jq .rewritesNaNtonullandserde_json/JSON.parsereject the file outright — in a side-car documented as plain, inspectable JSON. Both now raise before any file is touched:TypeErrorfor a non-str key,ValueErrorfor a non-finite float, each naming the exact path to the offending entry.Migration. The two halves differ in impact and it is worth being precise about which affects you:
- Non-str keys — no working code is affected. Those saves were
already lossy on reload (the keys came back as strings, and colliding
entries were simply gone), so the previous behaviour reported success
for a save that had not preserved the data. If you relied on it,
stringify the keys at the call site:
{str(k): v for k, v in ...}. - NaN / Infinity — this is a genuine break. Python's
jsonboth writes and reads the non-standard tokens, so such metadata did round-trip correctly through turbovec's ownsave/load, and that now raisesValueError. The change is still deliberate: the file those saves produced was not JSON, and every non-Python consumer either rejects it or (jq) quietly rewrites the value tonull. If you legitimately carry non-finite numbers, sanitize before saving —Nonefor "no value" (it round-trips asnulland is valid JSON), or a finite sentinel your pipeline agrees on.
Validation walks the whole payload, so serializing the side-car costs roughly twice what it did: on a 200k-document payload with 4-field metadata, 0.31 s → 0.65 s. That is the side-car step only; a full
save()also writes and fsyncs the index. - Non-str keys — no working code is affected. Those saves were
already lossy on reload (the keys came back as strings, and colliding
entries were simply gone), so the previous behaviour reported success
for a save that had not preserved the data. If you relied on it,
stringify the keys at the call site:
-
LangChain: a dict filter with a
Nonevalue no longer matches documents that lack the key (#381).filter={"g": None}was compiled todoc.metadata.get("g") is None, anddict.getcannot tell "absent" from "present and None" — so every document with nogkey at all came back. A dict entry now requires the key to be present, matching the predicate a user would write by hand and agreeing with the agno store's dict filter (#144). Absence is still expressible through the callable filter form (lambda doc: "g" not in doc.metadata), which is the only form langchain_core's ownInMemoryVectorStoreaccepts. -
Adds and removes on a loaded index are no longer permanently routed through the rayon pool (#392). The bindings chose between an uncontended fast path and a
py.detach+ pool handoff by probingpacked_ready(), which was documented as "false only until the first mutation after a load". That stopped being true: no mutation on a v6-loaded index materializes the packed bit-plane rows any more —addlazy-appends to the blocked cache andswap_removepatches it with O(dim) lane ops — so the probe stayed false for the index's whole lifetime and every add and remove paid a pool handoff costing far more than the operation.swap_removeand single-rowadddrop the probe entirely;IdMapIndex.removekeeps one, but onslots_ready(), the structure whose first build genuinely is O(n) with the GIL held (#319) and which does flip to true after one remove. The penalty was a fixed per-call pool handoff, so its size depends on how contended the pool is and is not a stable figure — measured between 20x and 280x the cost of the same operation on a fresh index across ops, thread counts and machine load, and in the worst samples far higher. After the fix a loaded index costs 1.4x a fresh one forIdMapIndex.remove, 2.2x forswap_removeand ~3x for a single-rowadd(100k × 128, 4-bit), at both default threads andRAYON_NUM_THREADS=1; those figures are stable and reproduce. Fresh-index cost is unchanged. The residual is real work a loaded index does and a fresh one does not — blocked-cache lane ops, and in the add case a per-call extract-LUT rebuild — not overhead. -
agno: a failed load no longer leaves a half-loaded store (#380).
_load_fromreplaced_index,_u64_to_doc,_next_u64and all three reverse indexes before the side-car/index consistency check that can raise, so a store whose load failed still reportedexists() is Trueand a retriedcreate()returned silently as "already created", handing back the half-load. The new state is now built into locals and committed in one block after every check has passed — a store whose load raised is one the method never touched. agno is the only integration that loads in place; the other three return a fresh object and were already safe. -
agno: a half-present save loaded silently empty and was then overwritten (#328).
create()caught theFileNotFoundErrorthat_load_fromraises for a folder holding only one ofindex.tvim/docstore.jsonand built a fresh empty index instead, so the nextsave()overwrote the surviving file and the data was gone. Only a folder with neither artifact is treated as a fresh path now; a partial store propagates the "missing one of ..." error. The other three stores load through explicit classmethods that already propagate, and are unchanged. -
write()errors now name the file and useFileNotFoundError(#329).TurboQuantIndex.write/IdMapIndex.writeraised a bareOSError: No such file or directory (os error 2)identifying no path, so a batch job writing several paths could not tell which failed andexcept FileNotFoundError:around a write never matched. Both now go through the same path-appending helperloaduses. Duplicate-id and zero-width-batch messages from the add path are corrected with the Rust-side change above, and a persisted-store corruption message no longer leads with the internal "handle" vocabulary. -
A zero-row
add/add_with_idsno longer commits a lazy index's dim (#308).TurboQuantIndex(bit_width=4)followed byidx.add(np.zeros((0, 768), np.float32))leftidx.dim == 768, so the next real batch of a different dimensionality raisedValueError: dim mismatch, and the wedged dim survivedwrite/loadandto_bytes/from_bytes. An empty batch is now the documented no-op:idx.dimstaysNoneandto_bytes()is byte-identical to a pristine lazy index. -
Framework integrations: four parallel implementations of the same semantics, brought back into line (#321, #302, #322, #301). The langchain / llama_index / haystack / agno stores each re-implement the same store contract, so a fix landed on one has repeatedly been missed on its siblings. This round closes four such gaps:
- agno: a failed
insertcould destroy a pre-existing document. The other three stores capture the previous state before the maps-first write and restore it when the index add raises; agno's unwind popped the handle unconditionally, so when a corruptnext_u64watermark reissued a live handle the unwind deleted the victim's payload and unlinked the new document's id and name. agno now captures and restores like its siblings, restoring the "a failed add never destroys existing data" guarantee. - Persisted-store validation now checks the handle watermark.
check_persisted_handlesverified duplicate handles, count parity and index membership, but never thatnext_u64sits at or above the largest handle in use — so a stale, hand-edited or partially-written side-car loaded cleanly and then failed every subsequent write with a leaked internal handle id. All four stores inherit the check. - llama_index:
delete(None)wiped every parentless node. Nodes with no SOURCE relationship storedref_doc_id = None, sodelete(node.ref_doc_id)on a parentless node deleted all of them. A parentless node is now filed under the literal"None", matchingSimpleVectorStore:delete(None)is a no-op,delete("None")targets them. - llama_index metadata filters: two divergences from
SimpleVectorStore.TEXT_MATCHis case-insensitive again (the reference lowercases both sides), and a missing metadata key now failsNE/NINas it already failed every other operator — the reference returnsFalsefor all operators once the value is absent. The previous behaviour was justified againstvector_stores.utils.build_metadata_filter_fn, which does not exist in the supported llama-index-core range;simple.pyholds the only in-tree evaluator and is now the reference of record. - langchain:
dot_productmode no longer fakes a[0, 1]relevance. The relevance mapping clamped, so every raw inner product>= 1.0became exactly1.0— asimilarity_score_thresholdretriever admitted unrelated documents, and the clamp also suppressed the out-of-range warningVectorStoreemits. Indot_productmode the mapping is now unclamped and selecting a relevance fn emits aUserWarning; cosine (the default) is unchanged and still clamped. - Reference-parity API gaps. langchain gains
similarity_search_with_score_by_vector(and itsa-prefixed variant) — the only non-deprecated public method theInMemoryVectorStorereference exposes and we lacked, so user code gotAttributeErrorrather thanNotImplementedError. haystack'sembedding_retrievalnow performs the reference's up-frontValueError("query_embedding should be a non-empty list of floats.")instead of returning[]on an empty store or reporting a dim mismatch.top_k=-1still raises here where the reference returnsn - 1documents: a negative count is a caller bug, not a request.
- agno: a failed
-
TQ+ calibration warm-up (#107, #284, #285, #303, #317). See the Rust-crate entry for the lifecycle change. On the Python side: both index types gain a read-only
calibration_stateproperty ("warming_up"/"fitted"/"identity"); saving an index that is still warming up emits a one-shotRuntimeWarning, because a file carries no warm-up buffer and the reloaded copy is committed to identity calibration for good; and the interruptibility wrapper no longer chunks an add into a warming-up index, since the calibrating add must see its whole batch to stay bit-identical to an unchunked one (it already made the same exception for the first add). -
Fork safety: turbovec no longer deadlocks in a
fork()ed child. rayon's thread pool does not survivefork()— its worker threads live only in the parent, so the first parallel op a forked child ran (a batch search, or anyadd) injected work into a worker-less registry and hung forever. This wedged the default configurations ofmultiprocessing(fork start method — the Linux default through 3.13), gunicorn--preload, Celery prefork, and PyTorchDataLoader(num_workers>0); single-query searches "worked" only by accident (a length-1 parallel iterator folds inline and never enters the pool), so smoke tests passed while the first batch or write silently wedged. The extension now routes every rayon-using kernel through a process-local pool: a forked child detects the fork (viaos.register_at_fork, with apthread_atforkbackstop) and transparently rebuilds the pool on its first call, so its parallel ops run on live workers. Inherited-index searches return identical results in parent and child; the single-query hot path is unchanged (within measurement noise). Remaining unsafe cases (documented, no library fix): forking while another thread is mid-add/search(POSIX async-signal-safety limit) and co-loaded OpenMP/MKL, which stays independently fork-unsafe. (#147) -
LlamaIndex: dotted namespaces no longer silently collide in a shared
persist_dir. The persistence stem handling usedwith_suffix, which re-split the stem at its last dot, so namespacesv1.2andv1.3both persisted tov1.tvim/v1.nodes.json— the second persist silently overwrote the first (data loss), andfrom_persist_dir(namespace="v1.2")returned the other namespace's data. Extensions are now appended to the full namespace-derived stem (v1.2__vector_store.tvim/v1.2__vector_store.nodes.json), so dotted namespaces coexist. Non-dotted namespaces keep byte-identical file paths — no migration. A store persisted by an earlier release under a dotted namespace still loads: when the correct filename is absent but the old mangled one exists,from_persist_pathfalls back to it (safe — the mangling meant at most one store could survive per mangled prefix), and the nextpersistwrites the correct names._validate_namespaceadditionally rejects:(a Windows drive-relative name likeC:fooescapespersist_dirwith no separator), extending the #152/#197 guard. (#200) -
Threshold and relevance-score paths are no longer broken for non-unit-normalized embeddings (the three wave-8 findings on #114 — all symptoms of the mappings assuming cosine input while the engine returned raw inner products; fixed by the
cosinedefault above, which makes the existing(sim + 1) / 2mappings correct):- LangChain:
as_retriever(search_type="similarity_score_threshold")returned[]for any threshold when all raw inner products fell below-1(every relevance clamped to0.0), and distinct high-scoring documents all clamped to relevance1.0(indistinguishable — no threshold could separate them). Relevance scores are now distinct, ordered, and threshold-usable. - Agno:
similarity_thresholdwas effectively inert — small thresholds discarded everything on all-negative raw scores and large thresholds admitted everything on large-positive ones. It now behaves as a true[0, 1]relevance cutoff under the default mode. - Haystack:
embedding_retrieval(..., scale_score=True)on the default cosine function collapsed distinct scores to1.0, violating the "same ranking, mapped into [0, 1]" contract; scaled scores are now distinct and order-preserving. (#114)
- LangChain:
-
Declared MSRV corrected from 1.70 to 1.83. Building the PyPI package from source (sdist, or a platform with no prebuilt wheel) now correctly requires rustc 1.83 — the toolchain the dependency tree has in fact required all along; the 1.70 declared in
turbovec-python/Cargo.tomlwas never sufficient. Prebuilt-wheel users are unaffected. (#182) -
Pickling a LlamaIndex store no longer silently returns an EMPTY store (total data loss).
pickle.dumps/pickle.loadsof a populated LlamaIndexTurboQuantVectorStoreappeared to succeed but dropped the Rust index on the floor: it lives in a pydanticPrivateAttr, and the inheritedBaseComponent.__getstate__removes any private attribute that failspickle.dumpswith only a log warning — so the store deserialized valid-looking and every query returned[]. This hit exactly the scenarios users pickle for (caching,multiprocessing, Ray, Celery): ship a populated store, workers silently search an empty one. The store now pickles faithfully via the newto_bytes/from_bytescore API; the LangChain, Haystack, and Agno stores — which previously raisedTypeError: cannot pickle 'builtins.IdMapIndex' object— now round-trip too. (#148) -
copy.copyof a store no longer aliases the Rust index, andcopy.deepcopyworks. A shallow copy of any of the four stores shared the underlying mutableIdMapIndexand side-car maps, so mutating the copy silently mutated the original (and vice versa) — andcopy.deepcopy, the only safe alternative, raisedTypeErrorbecause deepcopy rides the pickle path. Both now return a fully independent store (__copy__is deliberately identical to__deepcopy__; a shared-index "shallow" copy is precisely the bug). (#149) -
LlamaIndex
add()no longer loses data under concurrent calls._next_u64 += 1on a pydanticPrivateAttris not atomic under the GIL, so two concurrentadd()calls could issue the same handle — the second batch was rejected with an opaqueValueError: id already presentand its documents silently never stored (measured: 0.78% of adds under 2-thread ingest). Handle issuance now happens under the store's writer lock. (#161) -
Concurrent search / retrieval no longer raises transient
KeyError/RuntimeError: dictionary changed size during iterationwhile another thread writes. This closes the measured crash classes in all four stores, including Agno's delete-vs-delete cleanup-scan crash and the misleadingKeyError: allowlist contains id(s) not present in indexfrom a delete racing a filtered search. Load-time validation is unchanged: a corrupt persisted store still fails loudly at load. (#161) -
The x86_64 Linux and Windows wheels run on pre-AVX2 CPUs. The wheels are built inside the repo checkout, so they inherited the repo
.cargo/config.toml's globaltarget-cpu=x86-64-v3baseline: every plain (non-#[target_feature]) function — including the runtime dispatch and the scalar fallback itself — contained AVX/VEX instructions, and importing-and-searching on a pre-Haswell x86-64 CPU faulted (SIGILL) before the fallback could be selected. The wheels now build at thex86-64-v2baseline; AVX2/AVX-512 hardware still gets the#[target_feature]-gated SIMD kernels via runtime dispatch, compiled with their full feature sets regardless of the baseline (re-tuned by the baseline's tuning model). (#137) -
Agno's
TurboQuantVectorDbacceptsbit_width=3. The constructor guard rejected 3 even though the coreIdMapIndex— and the langchain, haystack, and llama_index stores built on it — fully support it; the guard now accepts{2, 3, 4}and still raisesValueErrorfor anything else. The integration docs and docstrings that described the contract as{2, 4}now state{2, 3, 4}, matching the core. (#138) -
LlamaIndex
TurboQuantVectorStore.from_persist_dir(namespace=...)rejects path-traversal namespaces.namespaceis composed into the side-car filename ({persist_dir}/{namespace}__vector_store.json), so a value containing a path separator or..(or an empty/.namespace) escapedpersist_dirand read an arbitrary sibling/parent file. Such a namespace now raisesValueErrornaming the offending value, rather than silently loading a different store than the caller named. Any other namespace (alphanumerics, dash, underscore) is accepted verbatim. This deliberately diverges fromSimpleVectorStore, which does not sanitize its namespace, in the safer direction. (#152) -
Optional-dependency floors now match what the integrations actually need. Three of the four extras declared minimums whose APIs the integration code relies on did not exist yet, so
pip install turbovec[haystack]/[agno]could resolve to versions that crash on import or construction. Empirically verified floors (lowest version where the full per-integration test file passes):extra old floor new floor langchainlangchain-core>=0.3unchanged (verified honest) llama-indexllama-index-core>=0.11unchanged (see next bullet) haystackhaystack-ai>=2.0haystack-ai>=2.23.0agnoagno>=2.0agno>=2.5.4haystack-ai below 2.1.0 was unimportable next to our integration, below 2.16.0 lacked
ByteStream.to_dict/from_dict(blob persistence), and below 2.23.0 could not serialize a pipeline containing the store; agno below 2.5.4 rejects thesimilarity_thresholdkwarg the store passes toVectorDb.__init__(and below 2.2.0 all of theid/name/descriptionkwargs too), so every construction failed. (#160) -
LlamaIndex
ALL/ANYmetadata filters no longer crash on llama-index-core 0.11.x–0.12.5. The operator dispatch referencedFilterOperator.TEXT_MATCH_INSENSITIVE(added in llama-index-core 0.12.6) unconditionally before theALL/ANYbranches, so on older releases those queries — and theFilterCondition.NOTdispatch — died withAttributeError. The newer enum members are now resolved withgetattrsentinels, keeping the honest>=0.11floor while still supportingTEXT_MATCH_INSENSITIVE/NOTwhere the installed version provides them. (#160) -
Loading a missing index file raises
FileNotFoundError.TurboQuantIndex.load/IdMapIndex.loadpreviously raised a bareOSErrorfor a nonexistent path, soexcept FileNotFoundError:never matched — and the LlamaIndexfrom_persist_pathwas the one integration load that surfaced it (the other three open their JSON side-car with Python'sopen()first). The binding now mapsio::ErrorKind::NotFoundtoFileNotFoundErrorand appends the offending path to every load error message; an existing-but-corrupt file still raises the plainOSErrorfamily. (#156) -
An over-large
RAYON_NUM_THREADSno longer makes the firstadd/searchdie with an uncatchablePanicException. A value above the OS thread limit (ulimit -u) made rayon's lazy global thread-pool construction fail (EAGAIN) and panic. The module now builds the pool at import time when the variable is set, clamping the request to 4x the available parallelism with aRuntimeWarningnaming the variable and the cap. When the variable is unset (or0, rayon's "auto"), nothing changes — rayon's lazy auto-sized pool is preserved exactly, and values at or under the cap keep producing byte-identical results. Note: an explicitly-set value above the cap that previously happened to work unclamped (e.g.2000under a permissive OS thread limit) is now clamped too and emits the warning; search/save results are byte-identical either way, only the thread count changes. (#158) -
The bindings release the GIL around compute-bound core calls.
TurboQuantIndex/IdMapIndexsearch,add/add_with_ids,prepare,write, andloadpreviously held the GIL for their full duration, stalling every other Python thread and serializing concurrent searches that the core index explicitly supports. The concurrency contract per index object is: reads (search,prepare,write,contains,len) may run in parallel with each other; a write (add,add_with_ids,remove,swap_remove) blocks until it has the index to itself and then succeeds — the same serialize-then-succeed outcome writes always had, now enforced by an internal reader-writer lock instead of the GIL. Inputs borrowed from numpy arrays (queries, vectors, ids, masks, allowlists) are snapshotted into owned buffers before the GIL is released, so a Python thread mutating an input array mid-call cannot corrupt or perturb the running operation. Long calls still cannot be interrupted with Ctrl-C mid-operation (signal polling is a separate concern). (#121) -
Added the
Operating System :: Microsoft :: Windowsclassifier to the PyPI metadata — Windows x64 wheels have shipped since 0.4.3 but the OS classifiers listed only Linux and macOS. (#143) -
LangChain: a
Noneentry in an explicitidslist is replaced with a generated UUID at add time (matching the referenceInMemoryVectorStore), instead of being stored asNoneand silently rewritten to the string"null"by adump/loadround-trip. (#124) -
LangChain:
add_textsalways returns a freshlist[str]— passingidsas a tuple previously returned the tuple unchanged. (#126) -
LangChain: a non-dict metadata entry (e.g.
None) is rejected with aTypeErrornaming the bad entry, before any state is touched. Previously the crash was an opaque'NoneType' object is not iterableraised after the vectors had been added to the index, leaving the docstore and index desynced in memory — and a subsequentdump()persisted the corruption. (#139) -
LangChain: generator / one-shot-iterable inputs are materialized once at each entry point (
add_texts/aadd_textsmetadatasandids,add_documents/aadd_documents,from_texts/afrom_texts), andfrom_textstests emptiness vialen()so a numpy array of texts works. Previously such inputs were iterated more than once — drained on the first pass — producing misleading length-mismatch /len()/ ambiguous-truth-value errors.delete/adeleteget the same treatment: a multi-element numpy array of ids previously crashed on theif not ids:emptiness test. (#157) -
LlamaIndex:
NE/NINmetadata filters now match nodes missing the filtered key, mirroring llama-index-core'sbuild_metadata_filter_fn. Previously such nodes were silently dropped from filteredquery,get_nodes, anddelete_nodesresults. (#132) -
LlamaIndex:
get_nodes(node_ids=...)returns nodes in requested-id order (previously storage/insertion order), consistent with the LangChain integration'sget_by_ids. The filters-only path keeps storage order. (#150) -
LlamaIndex:
add()accepts generators and other one-shot iterables. The input was iterated twice, so a generator drained on the first pass and crashed with a misleading "expected 2D embedding batch, got 1D"; it is now materialized once up front, asasync_addalready did. (#157, LlamaIndex case) -
Haystack:
embedding_retrieval(filters={})no longer raisesFilterError. An empty filter dict is now treated as "no filter", matching the referenceInMemoryDocumentStoreand the store's ownfilter_documents. (#131) -
Haystack:
write_documentsno longer crashes on aDocumentwhosemetaisNone(off-contract, butDocument(..., meta=None)keeps theNoneas-is); it is coerced to{}. (#139) -
agno:
insert()/upsert()accept numpy-array embeddings instead of raising numpy's truth-value-ambiguousValueError, matching LanceDb's tolerance. (#135) -
agno: a
None-valued metadata filter no longer matches documents missing the key.search(filters={"k": None})returned — anddelete_by_metadata({"k": None})deleted — the entire collection; both now require the key to be present and equal, matching LanceDb. (#144) -
agno: concurrent
async_upsertcalls of the samecontent_hashno longer retain stale generations. The previous generation's handles were captured before the awaited embed, so sibling tasks never removed each other's rows;async_upsertnow awaits only the embedding and delegates to the syncupsert(last-writer-wins, same as sync). (#146) -
agno:
drop()on a store with a persistencepathdeletes the on-disk artifacts (index.tvim/docstore.json), so a latercreate()starts empty instead of resurrecting the dropped data. Also,delete_by_content_id(None)/update_metadata(None, ...)are now no-ops instead of matching every document stored without acontent_id. (#169) -
agno:
search()/async_search()deduplicate duplicate-content results.LanceDb.search— the drop-in reference — unconditionally collapses the final result list bymd5(doc.content), keeping the first occurrence; turbovec returned every duplicate, handing callers (e.g. retrieved-chunks-to-LLM pipelines) duplicated context and a different result count. Per the maintainer ruling on the issue, dedup now runs as the final search step, after filtering and rerank — LanceDb's exact ordering — and, like LanceDb, without over-fetching, so a search returns fewer thanlimitdocuments when duplicate-content hits exist. Duplicate-content rows are still stored and individually deletable; only search results collapse. (#136) -
Wrong dtype/ndim array arguments raise a clear
TypeErrorthat names the argument and states expected vs got (e.g. "vectors must be a 2-D float32 array, got 2-D float64") instead of pyo3's opaque "'ndarray' object cannot be cast as 'ndarray'". Applies tovectors,queries,ids,mask, andallowlist; wrong dtypes are still rejected, never silently converted. (#127) -
Negative and out-of-
uint64-range integer arguments follow per-method semantics instead of raising a bare, context-freeOverflowError:swap_removeraises theIndexErrorits docstring documents, membership checks (in/contains/remove) returnFalsefor ids that can never be present, andk/dim/bit_widthraise aValueErrornaming the argument. (#128) -
Integration load paths now validate the side-car's internal key-sets, not just the handle ↔ index bijection. A desynced side-car (partial copy, stale backup, hand edit) previously loaded clean and failed later — an opaque
KeyErrormid-query, or silently missing results. All four integrations now raise a cleanValueErrorat load instead:- agno:
_load_fromskipped the index/side-car consistency check the other integrations run; a desynceddocstore.jsonloaded clean and orphaned vectors were silently dropped from search results. It now calls the shared handle check. (#115) - LangChain:
load()didn't requiredocsandstr_to_u64to hold the same document ids; adocsentry missing for a mapped id raisedKeyErrorinsidesimilarity_search. (#125) - LlamaIndex:
from_persist_pathdidn't requirenodesandnode_id_to_u64to hold the same node ids (nor the id map to be 1:1); a missingnodesentry raisedKeyErrorinsidequery(). (#133) - Haystack:
load_from_diskaccepted a side-car with duplicate document ids, which collapsed the rebuilt id map and left a shadow document that was searchable but unreachable by id.
- agno:
-
Integration saves no longer destroy a previously-good store on failure. All four framework integrations (LangChain
dump, LlamaIndexpersist, Haystacksave_to_disk, agnosave) wrote the index and then truncate-and-wrote the JSON side-car in place, so a save that failed mid-serialization — e.g. a document whose metadata holds asetor ndarray — left the destination with a new index and a truncated side-car, unloadable and unrecoverable. Saves now serialize the side-car fully in memory first (bad metadata raises before any file is touched) and write both files via fsynced sibling temp files moved into place withos.replace, removing the temp files on failure. (#159)
Benchmarks
-
Recall cells re-measured against the v5 rotation (#312). All six
benchmarks/results/recall_*.jsoncells were last regenerated atfbcbf26(2026-05-26) and so predated0cc381c, the format v5 block-Hadamard k=2 rotation the whole estimator rests on. They are re-measured here against a clean release build ofmain, anddocs/recall_{glove,d1536,d3072}.svgre-rendered from the new JSONs. TurboQuant R@1 moved in all six cells (GloVe 4-bit 0.8498 → 0.8553, GloVe 2-bit 0.5637 → 0.5695, d1536 4-bit 0.9740 → 0.9700, d1536 2-bit 0.8910 → 0.9030, d3072 4-bit 0.9740 → 0.9760, d3072 2-bit 0.9290 → 0.9310); the FAISSIndexPQbaseline reproduced its published R@1 to four decimals in all six, which is what identifies the movement as turbovec drift rather than an environment change. Two README claims are corrected accordingly: the OpenAI R@1 margin is 0.4–3.1 points (was 0.2–1.9), and on GloVe TurboQuant is now ahead at 2-bit by 0.5 points rather than "effectively tied", and ahead at 4-bit by 1.4 points rather than 0.9. Recall is a bit-exact, load-independent measurement — the suite records one arch-independent number per cell — and the re-run reproduced byte-identically across two independent invocations.compression.jsonwas re-measured at the same time and is unchanged apart from GloVe 2-bit (5.1 → 5.2 MB, same 14.8x ratio). Thespeed_*cells are not touched: they belong to the maintainer's GCP c3-standard-8 / c4a-standard-8 hosts and cannot be honestly re-measured elsewhere. See #312 for the remaining speed staleness. -
Official persistence cells, x86 insert re-measure, and ARM re-baseline (#279, #280). The published ARM benchmark environment moved from an Apple M3 Max laptop to a GCP c4a-standard-8 (Google Axion, 8 vCPU) instance — release build, idle box — and every ARM cell (search, insert, remove, persist) was re-measured there. The x86 cells stay on the same GCP c3-standard-8 (Sapphire Rapids) box; the x86 insert and persist cells were re-measured on a clean release build at the PR base commit (fresh
target/+maturin develop --release, provenance verified after an earlier run reused a pre-#277 build). The fresh clean-build run agreed with the committed x86 insert numbers within measurement noise across all 8 cells, so the committed bytes were retained: #277's encode speedup was measured on Cascade Lake and does not move Sapphire-Rapids bulk insert. (The agreement is what the ST≈MT single-add invariant confirms — singleadd()is serial, so a cell's ST and MT single-add timings must match, and across the grid they do.) All 16speed_persist_*cells (arm + x86, both threadings) are now recorded inbenchmarks/results/andcreate_diagrams.pyrenders matchingdocs/{arm,x86}_persist_{st,mt}.svgsave/load figures (save-warm and load→first-search as precision-matched TurboQuant-vs-FAISS pairs; the mutate→save→load→search round-trip, which FAISS has no measured equivalent for, shown TurboQuant-only). README search prose (ARM now 16–24%) and the ARM figure labels were updated to the new environment. -
Persistence benchmarks join the suite (#275):
speed_persist_*for every (dim, bit width, arch, threading) cell, covering write in both states (warm blocked cache vs invalidated by a mutation — ~5x apart, so a single "save time" would hide the interesting half),loadandload → first searchseparately (the gap v6 removed), andmutate → save → load → first searchas one checkpoint/resume pipeline, each against FAISSwrite_index/read_index. Page-cache state is warm and stated; the fsync + atomic rename turbovec does and FAISS does not is called out in the scripts as a deliberate durability difference rather than a gap to close. -
examples/insert_bench: a Rust harness reproducing the suite's four mutation metrics on deterministic synthetic vectors, so an optimization hypothesis can be measured in seconds without the OpenAI corpus or FAISS. Official numbers still come frombenchmarks/suite/. -
Insertion and removal speed benchmarks join the suite. (#65) For every published search-speed cell (d=1536/3072 × 2-bit/4-bit × ARM/x86 × ST/MT),
benchmarks/suite/gainsspeed_insert_*— bulkadd()into an empty index (rotation/codebook init + TQ+ calibration fit included), a warm append with calibration frozen (the steady-state encode path), single-vectoradd()latency, and a FAISSIndexPQFastScanbulk-add baseline — andspeed_remove_*—IdMapIndex.remove(id)per-op latency and throughput against a rawTurboQuantIndex.swap_removebaseline, isolating the id-map layer's bookkeeping. Fresh index per timed run (add is cumulative, remove shrinks the index), fixed seeds, median of 5, results inbenchmarks/results/like the rest of the suite. ARM (Apple M3 Max) and x86 (Intel Sapphire Rapids, c3-standard-8) results are both recorded.create_diagrams.pygains matching figures (insertion throughput ST/MT, removal latency) for both architectures. -
benchmarks/download_data.pydownloads to a.tmpsibling and renames into place on success, so an interrupted download no longer leaves a partial file at the final path that the existence guard then treats as complete. (#140) -
Misbehaving embedders now raise errors that name the embedder as the cause. (#154) Three error-quality gaps — no data corruption or desync in any of them, just opaque errors:
- LangChain
add_texts/aadd_textsvalidate that the embedder returned one vector per text. A short batch previously surfaced asexpected N ids, got Mfrom the index (or anIndexErrorwhen the batch contained duplicate ids); it now raisesembedder returned X vectors for Y texts. The query path likewise rejectsembed_queryreturningNoneor a non-1D result with an error naming the embedder instead of an opaque PyO3 boundaryTypeError. - Agno
insert/async_insertno longer crash withTypeError: object of type 'NoneType' has no len()when a batch embedder returnsNoneinstead of the embeddings list — the documents are treated as un-embedded and the existingfailed to embed N document(s)error is raised. - LangChain, Haystack, and LlamaIndex stores reject batches of empty
(dim-0) embeddings — shape
(N, 0)passed the 2D-batch guard and died in the index kernel withvector buffer length 0 not a multiple of dim 0; they now raise an error pointing at the embedder / embed model. (Agno already caught this mode via its missing-embedding check.)
- LangChain
CI
- MSRV leg: reads
rust-versionout of both manifests, checks they agree, and builds with exactly that toolchain. The declared MSRV has been wrong twice (1.70 → 1.83 → 1.89) and both times it took a human to notice; now it cannot drift from reality silently. - SIMD coverage gate, wired into the Rust legs.
TURBOVEC_REQUIRE_SIMD=avx2,avx512fmakes the kernel identity tests fail when a listed feature is missing rather than silently skipping the paths gated on it — without it a runner lacking a feature exercises nothing and still reports green, so the absence of coverage is invisible. CI setsavx2, which every GitHub-hosted x86 runner has. AVX-512 is deliberately not required there (hosted runners do not guarantee it), so those kernels remain single-machine-verified until a designated runner or an Intel SDE leg covers them. - Cross-OS encode fingerprint leg (#259).
examples/encode_hashencodes a fixed LCG fixture across six (dim, bit width) cells and prints a hash per pipeline stage — codebook, calibration, codes, scales, whole file. Each OS in the matrix runs it; aneeds:job fails unless all three agree. Hashing per stage means a divergence names the stage that drifted — which it did on the very first run: the codebook differed on all three OSes while calibration, codes and scales matched, localizing the cause to the boundary midpoints (fixed above) rather than to "the encode". Boundaries and centroids are hashed as separate columns for exactly that reason.
Docs
docs/api.mddocuments the rest of the index object model (#340): an index defines no__bool__, so truthiness falls through to__len__and an empty index is falsy —idx = idx or build_index()discards a valid empty index, andidx is Noneis the test to use. It also records that an index accepts no user attributes and is not subclassable, and why those pyclass options are deliberately not taken: an instance__dict__is not traversed by the garbage collector (a cycle through an attribute leaks the whole index) and its contents are dropped bypickle/copy, which carry only theto_bytespayload, while a subclass instance would pickle and copy back to the base class. Re-invokingidx.__init__(...)on a built index is documented as the no-op it is. Eight tests inturbovec-python/tests/test_object_model.pypin each statement.docs/api.md: the two FAISS analogues used as shorthand are replaced with direct descriptions —swap_removeis "not a shift" because the slots afterido not move down by one, andIdMapIndexis described as a hash-table-backedu64 id ↔ slotmapping rather than by comparison. (#344)- README gains an "Insertion & Removal Speed" section after Search Speed:
ARM insertion-throughput (ST/MT) and removal-latency figures generated
from the new
speed_insert_*/speed_remove_*results, with the measurement setup stated and results linked. x86 figures follow once the x86 cells are run. (#65) - Agno integration: the "Basic usage" example called
Knowledge.load_text(...), which no longer exists in current agno (2.7.x) and raisedAttributeErroron copy-paste. The example now usesknowledge.add_content(text_content=...). (#164) docs/api.md: two points where the Rust API doesn't mirror the Python examples are now called out — a lazy index's first add on the Rust API must useadd_2d/add_with_ids_2d(the flat forms require an already-committed dim and panic otherwise); and the allowlist result width ismin(k, unique ids in allowlist)— the allowlist is deduplicated, so repeated ids don't widen the result. (#168)- The
[Unreleased]compare link pointed at the stalev0.8.1tag, folding the entire 0.9.0 release into "Unreleased"; it now compares againstv0.9.0. (#143)
turbovec 0.8.0 (Python package) + turbovec 0.9.0 (Rust crate) — 2026-06-10
Security-audit release. Two adversarial audit passes over the core crate,
the Python binding, and the framework integrations, hardening the
untrusted-file load path and the Python API surface and fixing several
data-integrity bugs in the integration wrappers. Resolves #104, #105, and
#106. No on-disk format change (still .tv / .tvim v3).
A few fixes change observable behavior — see Changed under each surface. They turn previously-undefined or silently-wrong situations into clean, typed errors, so the bump is minor rather than patch.
turbovec — Rust crate (current: 0.8.1 → next: 0.9.0)
Fixed
- Untrusted index files are validated before allocation on load. A
crafted
.tv/.tvimcould previously trigger an integer-overflow in the packed-size computation, drive a multi-gigabyte allocation from a tiny file, divide-by-zero in the repack step, or load a structurally invalid index that returned silently-wrong scores (abit_widthof 5–8 passed the old length check). The loader now range-checksbit_widthanddim, computes every size with checked arithmetic, and reads each payload through a length-capped reader. (#105) - x86 scalar fallback returned wrong results. On pre-AVX2 x86 (or VMs
without AVX2),
score_query_into_heapread the perm0-interleaved code layout as if sequential, producing an incorrect top-k. It now de-interleaves correctly; verified end-to-end against the SIMD kernels on AVX-512 hardware. (#106)
Changed
AddErrorandConstructErrorare now#[non_exhaustive]. Downstreammatchon these enums must carry a wildcard arm; in exchange, future error variants are no longer breaking changes. (The newDimTooLargevariant is why this release is the moment to make the switch.)dimis capped atMAX_DIM(65536) at construction, first add, and load.searchlazily builds adim×dimrotation matrix whose size is not bounded by any file, so an unboundeddimwas a load-time resource-exhaustion vector. Larger dims now return a typed error.- A zero-
dimlazy add is rejected withAddErrorinstead of panicking with a divide-by-zero and wedging the index.
Removed
- Dead, untested
pack::repack_3bit(no callers; 3-bit goes throughrepack).
Other
- The crate now fails to compile on non-64-bit targets (a
compile_error!gated ontarget_pointer_width). The size/offset arithmetic inencode/pack/searchassumes 64-bitusize; refusing to build on 32-bit/wasm avoids shipping a silently-unsafe (potential out-of-bounds) build there.
turbovec — Python package (current: 0.7.1 → next: 0.8.0)
Fixed
search()no longer panics on NaN / Inf / oversized query coordinates. These previously raised an uncatchablePanicException(aBaseException); they now raiseValueError, matchingadd. (#105)- Loading a malformed
.tv/.tvimraises a clean error instead of panicking or driving a huge allocation (the Rust load-path hardening above, surfaced through the binding). - agno: duplicate derived
doc_idno longer orphans vectors. Two documents that derive the same id (a repeateddoc.id, or identical content) are both kept and both deletable, matching agno's reference store (LanceDb appends). Previously the earlier vector was counted and searchable but unreachable by id, and leaked on every upsert. (#104) - agno:
delete_by_name/delete_by_content_id/delete_by_metadatano longer over-delete. When distinct documents collided on a content-deriveddoc_id, deleting by one attribute also deleted the id-twin; deletion now targets only the documents matching the predicate. - LangChain / Haystack / LlamaIndex: a persisted JSON side-car that is out
of sync with its
.tvimindex now raises aValueErrorat load instead of an opaqueKeyErrordeep inside a later query. - Internal binding result-shape errors map to
RuntimeErrorrather than an uncatchable panic.
Changed
search()and the index constructors now raiseValueErrorfor non-finite query values and fordimoutside the supported range, where some of these inputs previously panicked or were silently accepted.
Docs
- Corrected stale benchmark figures in the README (recall deltas, ARM/x86
speed ranges) to match the current
benchmarks/results/; several had drifted from before the TQ+ calibration step landed.
turbovec 0.7.1 (Python package) + turbovec 0.8.1 (Rust crate) — 2026-06-09
Bug-fix release. Two data-safety fixes in the Python integration wrappers'
add/upsert paths, plus a source-build fix for the Python extension on macOS.
The Rust crate is functionally unchanged — only non-behavioral cleanups —
but is re-released to keep crates.io in sync with the source tree. No
on-disk format change (still .tv / .tvim v3).
turbovec — Rust crate (current: 0.8.0 → next: 0.8.1)
Changed
- Internal cleanup only, no behavior change — the published crate
behaves identically to 0.8.0. Cleared three build warnings (two unused
bindings in the NEON scoring kernels; the scalar
score_query_into_heapfallback is nowcfg-gated out ofaarch64builds, where the NEON kernel is always used and it was dead code) and corrected stale SIMD module/kernel doc comments.
turbovec — Python package (current: 0.7.0 → next: 0.7.1)
Fixed
- Intra-batch duplicate ids no longer orphan vectors in the LangChain
and Haystack integrations. A repeated id within a single
add_texts/add_documents/write_documentscall previously added one vector per row while the id→handle map kept only the last, leaving the earlier vectors live in search but mapped to the wrong document and unreachable for delete. Both now resolve duplicates the way their reference stores do — LangChain (InMemoryVectorStore) keeps the last occurrence; Haystack (InMemoryDocumentStore) applies theDuplicatePolicyagainst the batch-so-far. Fixes #90. - Upsert no longer destroys existing data when the new batch fails
validation, across all four integrations (LangChain, LlamaIndex,
Haystack, Agno). The old vectors for matching ids were deleted before
the incoming batch was validated/encoded, so a dimension change or a
non-finite embedding left the store with the originals already gone. The
delete is now deferred until after the add succeeds (Agno captures the
previous generation's handles and removes them after
insert). Fixes #89. - Plain
cargo buildof the extension now links on macOS. Buildingturbovec-pythonfrom source failed with "symbol(s) not found for architecture arm64" because nothing emitted the Python extension-module linker args (maturin injects them; a barecargo builddid not). Added abuild.rscallingpyo3_build_config::add_extension_module_link_args(). Prebuilt wheels were unaffected. Fixes #92.
turbovec 0.7.0 (Python package) + turbovec 0.8.0 (Rust crate) — 2026-05-30
Audit-driven correctness pass on every layer (Rust core, Python bindings,
four integration wrappers). Headline: 14 active bugs found and fixed,
hundreds of regression tests added, doc drift cleaned up across the
public API. No on-disk format change (still .tv / .tvim v3).
turbovec — Rust crate (current: 0.7.0 → next: 0.8.0)
Added
AddError::InvalidInputValue { vector_index, coord_index, value }— new error variant returned byTurboQuantIndex::add_2dandIdMapIndex::add_with_ids_2dwhen an input coordinate is non-finite (NaN, +Inf, -Inf) or has magnitude>= 1e16. Without this validation the encode pipeline silently corrupted the index: NaN/Inf propagated throughsimd_normand poisonedvec_scales[slot] = NaN, making the slot exist inlen()but unreachable throughsearch; huge magnitudes overflowed the f32 norm to+Inf, making the slot win every query.- Scalar fallback in the x86_64 search dispatch. Previously,
searchon an x86_64 CPU without AVX-512 BW or AVX2 silently returned empty top-k results for every query (the SIMDunsafe { if/else if }block had noelse). Rare in practice on modern hardware but the failure mode was the worst kind.
Changed
- Breaking:
AddErrorno longer derivesEq(the newInvalidInputValuevariant carries anf32, which isn'tEqbecauseNaN != NaN).PartialEqis still derived. Downstream code that pattern-matchesAddErrorexhaustively will need to add the new variant. TurboQuantIndex::add/add_2d/search/search_with_masknow reject non-finite / huge-magnitude inputs at entry.addandsearchpanic with a clear message (matching their existing precondition- panic style);add_2dandadd_with_ids_2dreturnErr(InvalidInputValue)for callers handling untrusted input.TurboQuantIndex::from_partsasserts structural invariants (packed_codes / scales / TQ+ length relationships) at entry, catching any future caller that bypasses the read-layer validation.- Rustdoc on
add,add_2d,search,search_with_mask, andIdMapIndex::add_with_idsnow documents every panic condition introduced by the input validation.
Fixed
IdMapIndex::add_with_ids_2dpartial-mutation on inner failure. ID tables (id_to_slot/slot_to_id) were mutated before delegating to the inneradd_2d. If the inner call returnedErr(e.g.DimMismatchon a committed-dim index), the ID tables retainednghost entries pointing at slots that didn't exist in the inner index — corrupting latersearch_with_allowlist/remove. Fixed by capturingbase_slotbefore, running inner add first, mutating ID tables only on success.- v2-loaded index +
addsilently mis-encoded new vectors. Loading a pre-TQ+ (v2) file lefttqplus_shiftempty; the nextaddsawexisting = None, fit fresh calibration, encoded the new batch with that calibration — but then silently dropped the fitted shift/scale because then_vectors != 0else branch only extendedpacked_codes/scales. The new vectors then got searched against identity calibration, producing silently wrong scores. Fixed by populating explicit identity TQ+ infrom_partswhen loading a v2-shaped state. - Empty first add froze identity calibration forever.
add(&[])hit then < TQPLUS_MIN_SAMPLESbranch inencode, returned identity, and then_vectors == 0branch wrote it toself.tqplus_shift. Every subsequent add — even a million-vector batch with rich distribution — then sawexisting = Some(identity)and silently skipped fresh fitting. Fixed by short-circuitingaddto a true no-op whenn == 0.
turbovec — Python package (current: 0.6.0 → next: 0.7.0)
Changed
- Breaking (typed-exception hygiene):
TurboQuantIndex.add/searchandIdMapIndex.add_with_ids/searchnow raiseValueErrorfor non-finite or huge-magnitude coordinates, non- contiguous numpy arrays, and wrong-dim queries. Previously these surfaced as Rust panics →PanicExceptionin Python. - Breaking:
TurboQuantIndex.swap_removenow raisesIndexErrorfor out-of-range indices (was a Rust panic). IdMapIndex.searchandTurboQuantIndex.searchnow return consistent shapes for empty queries —(0, min(k, n_vectors, n_allowed))on both. PreviouslyIdMapIndexreturned(0, k)(rawk), diverging fromTurboQuantIndex's(0, min(k, n)). ForIdMapIndex, theeffective_kcomputation also now dedups the allowlist for thenq == 0path, matching the kernel's mask-based dedup fornq > 0.
Fixed
turbovec.langchain.TurboQuantVectorStore:similarity_search,similarity_search_with_score, andsimilarity_search_by_vectornow populateDocument.idon returned hits (was silentlyNone). TheDocumentpassed to user-supplied filter callables also carries.idso predicates can filter on it. Fixes #81.turbovec.haystack.TurboQuantDocumentStore:Document.blobandDocument.sparse_embeddingnow survive write → retrieval round-trip (were silently dropped). Docstore schema bumpsv1 → v2with backward-compat load. Filter shape validation tightened to matchInMemoryDocumentStore(bare{"field": "x"}shapes are rejected). Docstring scoped back from "matches the public surface ofInMemoryDocumentStore" sincebm25_retrievalis not implemented.turbovec.llama_index.TurboQuantVectorStore: fullBaseNodefidelity throughquery/get_nodes/ persist+load. PREVIOUS / NEXT / PARENT / CHILD relationships,excluded_embed_metadata_keys/excluded_llm_metadata_keys, template fields (text_template,metadata_template,metadata_separator),start_char_idx/end_char_idx, andmimetypewere silently dropped — now preserved vianode_to_metadata_dict/metadata_dict_to_node. Nodes schema bumpsv1 → v2with backward-compat load. Plus:FilterCondition.NOTnow supported (wasNotImplementedError).FilterOperator.TEXT_MATCHis now case-sensitive (matches the reference; previously silently lowercased both sides).FilterOperator.TEXT_MATCH_INSENSITIVE,ALL,ANYadded.query.mode != VectorStoreQueryMode.DEFAULTraisesNotImplementedErrorinstead of silently behaving as DEFAULT.add()rejects intra-batch duplicatenode_ids with a clearValueError(previously, the index ended up with both vectors but only the last node_id mapped back to one, orphaning the first handle and silently returning the second node's payload attached to the first node's vector).
turbovec.agno.TurboQuantVectorDb:embedderis now threaded through returnedDocumentobjects sodoc.embed()/doc.async_embed()work on retrieved hits (previously raised "No embedder provided"). Empty query strings short-circuit to[](matching LanceDb).
turbovec 0.6.0 (Python package) + turbovec 0.7.0 (Rust crate) — 2026-05-27
turbovec — Rust crate (current: 0.6.0 → next: 0.7.0)
Added
-
TQ+ per-coordinate calibration. Before the data-oblivious rotation, every coordinate is shifted by its empirical 5th percentile and scaled so that the 5–95% range maps to
[0, 1]. The shift/scale pair is fit incrementally from the cold-pathadddata, so the index stays online — no separate train pass, no rebuilds as the corpus grows. At search time, the same affine is applied to the query before the rotation. Recall@1 lifts across published cells:- GloVe-200 4-bit: 0.8440 → 0.8498 (+0.6pp)
- OpenAI-1536 2-bit: 0.876 → 0.891 (+1.5pp)
- OpenAI-1536 4-bit: 0.966 → 0.974 (+0.8pp)
- OpenAI-3072 2-bit: 0.911 → 0.929 (+1.8pp)
- OpenAI-3072 4-bit: 0.971 → 0.974 (+0.3pp)
No public API change — TQ+ is always-on. The cost is one extra pass per
addbatch to update the running quantile estimates, paid once on the cold path; search latency is essentially unchanged. -
Cross-arch top-K parity. The AVX2 and AVX-512 BW kernels now produce byte-identical top-K result sets to the NEON kernel for any deterministic input. Per-vector f32 scores still differ by ~1e-5 relative across arches (different SIMD reduction orders), but those rank swaps are confined to within-tie vectors and never change set membership. Verified via the new
examples/kernel_xtest.rssmoke test (sha256 of sorted-per-query top-K indices matches across all three SIMD paths).
Changed
-
On-disk format version bumped to 3 for both
.tvand.tvim. v3 appends a TQ+ trailer (per-coord shift + scale arrays) after the existing scales section. The v3 reader is backward-compatible: v2 files load with empty TQ+ vectors (identity calibration). Files written by 0.7.x cannot be loaded by 0.6.x or older; there's no forward-compat shim. Reindexing from source vectors picks up the TQ+ recall lift; loading an old v2 file gives you the pre-TQ+ numbers. -
x86 LUT-build is no longer data-dependent. The AVX2 and AVX-512 BW kernels previously capped
max_lutatmin(127, 65535 / n_byte_groups)to keep their no-flush u8→i16 accumulators in range — which at d=1536/4-bit clamped to 42, and at d=3072/4-bit to 21, opening a visible recall gap vs ARM (−1.6pp and −5.5pp respectively). Both kernels now batch the inner loop byFLUSH_EVERY=256byte-groups and run a mini-epilogue (SUB-trick + i16→f32 + fmadd into per-query f32 accumulators) at the end of each batch — the same structure NEON has used since 0.5.x.max_lutis now unconditionally 127 on every arch. x86 speed is essentially flat vs the previous release (the per-batch flush eliminates the same work from the single final epilogue).
turbovec — Python package (current: 0.5.3 → next: 0.6.0)
Added
- TQ+ per-coordinate calibration. Same kernel-level change as the
Rust crate; Python users see no API change.
TurboQuantIndex.add()carries a small extra pass per batch to update the running quantile estimates (one-shot cold-path cost; search latency unchanged), and.search()returns higher recall on the cells listed above. The README's "How it works" section documents the calibration step.
Changed
- On-disk format version bumped to 3 for both
.tvand.tvim. Same forward-compat policy as the Rust crate: old v2 files load fine into 0.6.0+ (with identity calibration), but indexes written by 0.6.0+ cannot be loaded by ≤ 0.5.3. Reindex from source vectors to pick up the recall lift.
Fixed
- x86/ARM recall parity at d=1536 and d=3072, 4-bit. Previous releases silently produced lower recall on x86 than ARM at high dim — most visibly at d=3072/4-bit where x86 measured 0.919 @1 vs ARM's 0.974 (−5.5pp). Same fix as the Rust crate (porting the ARM-style periodic accumulator flush to AVX2 and AVX-512 BW). x86 search latency is essentially unchanged.
turbovec 0.5.3 (Python package) + turbovec 0.6.0 (Rust crate) — 2026-05-25
turbovec — Rust crate (current: 0.5.0 → next: 0.6.0)
Changed
-
BREAKING:
TurboQuantIndex::new,TurboQuantIndex::new_lazy,IdMapIndex::new, andIdMapIndex::new_lazynow returnResult<Self, ConstructError>instead of panicking on invalid input. The newturbovec::ConstructErrorenum coversbit_widthout of{2, 3, 4}anddimnot a positive multiple of 8 (which also closes a latent hole wheredim = 0was silently accepted — the previousdim % 8 == 0assertion vacuously passed for zero, then divided-by-zero on the firstadd).Migration: append
?(or.unwrap()in tests/binaries) to existing constructor calls. Mirrors theAddErrorpattern from the previous release. -
Encode is 2–3× faster on aarch64. SIMD-ifies the quantize + scale + bit-pack inner loop via NEON (compare against boundaries in 8 lanes at a time, weighted horizontal-add for the bit-pack) and fuses the three passes so there's no intermediate
codes: Vec<u8>allocation. Rayon parallelises across rows on both aarch64 and x86_64; x86_64 keeps the existing scalar inner loop. Recall is bit-identical to the previous release at every published cell (verified againstbenchmarks/suite/recall_*.pyon M3 Max). Measured throughput on M2 Pro, single-threaded:- d=768, 4-bit: 22.5K → 66.3K vec/sec (2.9×)
- d=1536, 4-bit: 9.5K → 21.9K vec/sec (2.3×)
- d=1536, 2-bit: 16.6K → 25.7K vec/sec (1.5×)
-
Codebook is now cached across incremental
addcalls. The Lloyd-Max boundaries and centroids are a deterministic function of(bit_width, dim), so recomputing them on everyaddwas wasted work. They're now stored inOnceLockcells (the same pattern already used for the rotation matrix) and reused across calls. No behaviour change; faster incremental indexing.
turbovec — Python package (current: 0.5.2 → next: 0.5.3)
Fixed
- Linux wheels now actually import. Every Linux wheel since
Linux build support was added had a missing
DT_NEEDEDentry forlibopenblas, soimport turbovecfailed at the dynamic linker step withundefined symbol: cblas_sgemm— even on systems that had OpenBLAS installed. The wheel now declares the dependency explicitly, andauditwheelbundles a self-contained copy oflibopenblas(plus itslibgfortran/libquadmathruntime deps) intoturbovec.libs/. Linux wheel size grows from ~1.8 MB to ~11 MB (aarch64) / ~42 MB (x86_64) as a consequence — the bundled OpenBLAS contains kernel variants for many micro-archs and dispatches at runtime. The Linux release CI now also runspytestagainst the freshly-built wheel on native runners so this class of bug can't ship silently again.
Changed
-
TurboQuantIndexandIdMapIndexconstructors raiseValueErroron bad input (bit_widthoutside{2, 3, 4},dimnot a positive multiple of 8, including the previously silently-accepteddim = 0case). Previously these surfaced aspyo3_runtime.PanicException, which subclassesBaseExceptionand so wasn't caught byexcept Exception:— user code can now recover from a configuration error as a normal usage error. -
Encode (build-time, not query-time) is faster on aarch64. Same kernel-level change as the Rust crate; Python users see no API change and bit-identical recall at every published cell. Building an index with
TurboQuantIndex.add()is ~2–3× faster on M-series macOS and Linux aarch64. x86_64 sees the Rayon parallelism but not the SIMD kernel.
turbovec 0.5.2 (Python package) + turbovec 0.5.0 (Rust crate) — 2026-05-21
turbovec — Rust crate (current: 0.4.1 → next: 0.5.0)
Changed
-
BREAKING:
TurboQuantIndex::add_2d,IdMapIndex::add_with_ids_2d, andIdMapIndex::add_with_idsnow returnResult<(), AddError>instead of panicking on invalid input. The newturbovec::AddErrorenum covers dim mismatch,dim % 8 != 0on lazy-commit, vector buffer length not a multiple ofdim, ids/vectors count mismatch, and duplicate ids. The low-levelTurboQuantIndex::add(&[f32])and constructor asserts are unchanged — they still panic, since those signal contract violations rather than user-input errors.Migration: append
?(or.unwrap()in tests/binaries) to existing calls. Match onAddErrorif you need to recover from specific failure modes.
turbovec — Python package (current: 0.5.1 → next: 0.5.2)
Changed
- Dim mismatch on
add/add_with_idsnow raisesValueErrorinstead of surfacing apyo3_runtime.PanicExceptionwith a Rust backtrace. The previousPanicExceptionsubclassedBaseExceptionand so was not caught byexcept Exception:— user code can now recover from a wrong-shape batch as a normal usage error. The same applies to duplicate ids and length mismatches onIdMapIndex.add_with_ids.
turbovec 0.5.1 (Python package) + turbovec 0.4.1 (Rust crate) — 2026-05-18
turbovec — Rust crate (current: 0.4.0 → next: 0.4.1)
Added
-
Block-level early exit for selective mask searches (closes #30). When a search is issued with
Some(mask)the SIMD kernels now check whether each 32-vector block contains any allowed slots before doing the LUT lookup + popcount + score-decode work for that block. If not, the entire block is short-circuited at one integer-load + branch per block. The AVX-512BW path additionally short-circuits 64-vector pairs at once where possible.Measured speedup at 1% selectivity, 100K vectors, d=1536 (mask allowing the last 1K slots): 6.4× on ARM (M3 Max), 12.7× on x86 (Sapphire Rapids c3-standard-8). Unmasked search latency is unchanged (the guard only fires when a mask is passed).
Public API: no change to existing surfaces.
-
turbovec::search::BLOCKS_SKIPPED_BY_MASK— atomic counter incremented each time a block is short-circuited. Accessorsblocks_skipped_by_mask()andreset_blocks_skipped_by_mask()are exposed for hybrid-retrieval telemetry. AVX-512BW pair-level skips count as 2.
turbovec — Python package (current: 0.5.0 → next: 0.5.1)
Added
- Block-level early exit for selective
search_with_maskcalls. Same kernel-level change as the Rust crate; Python users see identical API and unchanged unmasked latency. Selective masks now run substantially faster (≈6–13× at 1% selectivity, scaling with index size — larger indices amortize fixed per-query cost more and see larger speedups). Closes #30.
turbovec 0.5.0 (Python package) + turbovec 0.4.0 (Rust crate) — 2026-05-18
BREAKING — on-disk file format version bumped from 1 to 2. Existing
.tvand.tvimfiles written by turbovec ≤ 0.4.3 cannot be loaded by 0.5.0+. Reindex from source vectors to migrate; no in-place migration is provided.
Migration
If you have indexes built with 0.4.3 or earlier, re-encode them:
import numpy as np
from turbovec import TurboQuantIndex
# Source vectors (the f32 inputs your old index was built from).
vectors = np.load("my_vectors.npy") # shape (n, dim)
# Build a fresh 0.5.0 index. Same API, same recall guarantees, but with
# the new length-renormalization correction applied.
index = TurboQuantIndex(dim=vectors.shape[1], bit_width=4)
index.add(vectors)
index.write("my_index_v2.tv")
If you load an old file under 0.5.0+, you will see:
this .tv file was written by turbovec ≤ 0.4.3 (format version 1).
It is incompatible with turbovec 0.4.4+ because the per-vector scalar's
meaning changed. Rebuild this index from the source vectors using
turbovec 0.4.4 or later.
turbovec — Rust crate (current: 0.3.0 → next: 0.4.0)
Added
- Length-renormalized scoring. The per-vector scalar stored in
TurboQuantIndexis now||v|| / <u_rot, x̂>instead of||v||, giving an unbiased estimator of the inner product. The SIMD kernel multiplies by this value at the same site it previously used the norm — no change to kernel speed, storage layout, or public API.
Changed
- On-disk format version bumped to 2 for both
.tvand.tvim..tvnow starts with a 4-byte magic"TVPI"+ 1-byte version prefix;.tvimkeeps its existing magic with version bumped from 1 to 2. Loading a v1 file returnsio::Errorof kindInvalidDatawith an upgrade-hint message; no in-place migration is provided. TurboQuantIndex::normsfield renamed toscales. Internal rename to match the value's new meaning. The SIMD kernel parameter isvec_scales(to disambiguate from the per-query LUT calibrationscalesparameter inside the same functions).
turbovec — Python package (current: 0.4.3 → next: 0.5.0)
Added
-
Length-renormalized scoring. Replaces the per-vector
||v||scalar with a RaBitQ-style correction||v|| / <u_rot, x̂>that removes the systematic bias of the inner-product estimator. The SIMD kernel is byte-for-byte unchanged — it multiplies by the new scalar at the same site it previously used the norm. Recall@1 gains across published benchmarks:- GloVe-200 2-bit: 0.5053 → 0.5524 (+4.7pp)
- GloVe-200 4-bit: 0.8115 → 0.8440 (+3.3pp)
- OpenAI-1536 2-bit: 0.8700 → 0.9060 (+3.6pp)
- OpenAI-1536 4-bit: 0.9550 → 0.9700 (+1.5pp)
- OpenAI-3072 2-bit: 0.9120 → 0.9240 (+1.2pp)
- OpenAI-3072 4-bit: 0.9670 → 0.9800 (+1.3pp)
Same-session ARM and x86 speed benchmarks confirm no measurable search-latency change (deltas within FAISS noise floor on every cell). The correction adds one extra dot product per vector at encode time — a one-shot cost on the cold path, not visible to search.
Changed
- On-disk format version bumped to 2 for both
.tvand.tvim..tvfiles now start with a 4-byte magic"TVPI"+ 1-byte version..tvimfiles use the existing magic with version byte bumped from 1 to 2. - Loading a turbovec ≤ 0.4.3 index raises with a clear error.
The per-vector scalar's meaning changed (
||v||→||v|| / <u_rot, x̂>), so silently re-interpreting v1 files would produce wrong scores. The new loader detects v1 files by their format signature and raisesOSErrorpointing the caller at rebuilding from source vectors.
Fixed
turbovec.haystack.TurboQuantDocumentStoreclamps cosine scores to[-1, 1]beforescale_scorerescaling. Cauchy–Schwarz bounds the true cosine in that range, but the LUT scoring kernel's float-precision noise can produce values slightly outside it — most visibly on a self-query, which is algebraically 1.0 but the kernel produces ~1.00016 after its per-sub-table calibration. Without the clamp, downstream consumers ofscale_score=Truesaw scores> 1.0and the[0, 1]contract was violated. Dot-product path uses a sigmoid that is already bounded; no clamp needed there.
turbovec 0.4.3 (Python package) — 2026-05-18
turbovec — Python package (current: 0.4.2 → next: 0.4.3)
Added
-
Windows x64 wheel (closes #31). Prior releases shipped only Linux x86_64/aarch64, macOS aarch64, and an sdist — Windows users running
pip install turbovecfell through to the sdist and hit alink.exebuild failure unless they had Rust + MSVC installed locally. The release workflow now also builds acp39-abi3-win_amd64wheel and validates it by installing and running the core pytest suite (test_index.py,test_id_map.py,test_filtering.py) on the build runner before upload. Implementation in #33.Intel Mac (macOS x86_64) was considered alongside Windows but blocked by GitHub's December 2025 deprecation of free-tier
macos-13runners; tracked separately in #34.No library changes in this release — same Python API, same on-disk format, same recall and throughput as 0.4.2. Pure platform-coverage patch.
turbovec 0.4.2 (Python package) — 2026-05-17
turbovec — Python package (current: 0.4.1 → next: 0.4.2)
Fixed
numpyis now a declared runtime dependency. The Python package and every integration module importsnumpyunconditionally, and the Rust extension's Python surface expects NumPy arrays as input. Prior releases relied onnumpybeing pulled in transitively via the framework extras (langchain-core,llama-index-core,haystack-ai). This brokepip install turbovec[agno]in clean environments becauseagnodoesn't depend onnumpy.numpy>=1.20is now declared in[project].dependencies, so it's installed regardless of which extra (or none) is selected.
turbovec 0.4.1 (Python package) — 2026-05-17
turbovec — Python package (current: 0.4.0 → next: 0.4.1)
Added
- Agno integration (
turbovec.agno). NewTurboQuantVectorDbclass implementing Agno'sVectorDbinterface, structurally aligned withagno.vectordb.lancedb.LanceDb(the closest in-tree single-machine backend). Drop-in for callers that useLanceDbas their Agno knowledge backend.- Dim is sourced from
embedder.dimensions(matchesLanceDb); no baked-in default. - Filtered search uses the kernel-level
allowlist=path: filters resolve to a handle allowlist before scoring, so selective filters return up tolimitresults from the filtered set instead of fewer-than-limitfrom a post-filter. - JSON side-car persistence (no pickle, no
allow_dangerous_deserializationflag). - Constructor restricts
search_type=vectoranddistance=cosine— turbovec doesn't ship a BM25/lexical index and stores unit-normalized vectors only. Non-vector / non-cosine constructions raiseValueErrorrather than silently misbehaving. - Honours
similarity_threshold(cosine → relevance clamped to[0, 1]via(s + 1) / 2),reranker(optional rerank pass after vector retrieval),content_id/content_hashpayload fields. - Full async surface:
async_*variants for create/insert/upsert/ search/drop/exists/name_exists, using the embedder's async batch paths when available. - Install:
pip install turbovec[agno].
- Dim is sourced from
turbovec 0.3.0 (Rust crate) — 2026-05-17
turbovec — Rust crate (current: 0.2.0 → next: 0.3.0)
Added
-
Search-time filtering. New methods restrict the returned top-k to a caller-supplied subset of vectors. The kernel applies the filter at the heap-update site rather than via post-filtering, so selective filters return up to
kresults from the allowed set instead of fewer-than-kfrom an over-fetch pass. Output shape shrinks tomin(k, n_allowed)— consistent with the existingk > len(idx)contract; no sentinel padding. (#21)TurboQuantIndex::search_with_mask(queries, k, mask: Option<&[bool]>)— slot bitmask, length equal tolen(idx).IdMapIndex::search_with_allowlist(queries, k, allowlist: Option<&[u64]>)— external-id allowlist; translated to a slot bitmask internally via the existingid_to_slotmap. Panics on empty allowlist or unknown ids.- Threaded through every scoring path: NEON (aarch64), AVX2 (x86_64), AVX-512BW (x86_64), and the scalar fallback.
-
Lazy index construction. The dim can now be deferred and inferred from the first batch of vectors, rather than committed at construction time. This is the same ergonomic improvement integration users were already getting through the framework wrappers, pulled down into the core so direct Rust users and any future integration get it for free.
TurboQuantIndex::new_lazy(bit_width)andIdMapIndex::new_lazy(bit_width)— construct an empty index with no committed dim.TurboQuantIndex::add_2d(vectors, dim)andIdMapIndex::add_with_ids_2d(vectors, dim, ids)— add a flat vector batch with an explicit dim; locks the index dim on the first call, validates on subsequent ones. Existingadd(&[f32])/add_with_ids(&[f32], &[u64])still work on a dim-known index and panic with a clear message on a lazy uncommitted one.TurboQuantIndex::dim_opt()/IdMapIndex::dim_opt()returnOption<usize>—Nonefor the lazy uncommitted state. The existingdim() -> usizegetters keep returningusize, with0as a non-breaking sentinel for the lazy state (the eager constructor assertsdim >= 8, so0doesn't collide).- File format:
.tvand.tvimheaders encode the lazy state via adim = 0sentinel. Files written before this change always havedim >= 8and load cleanly into the eager state.
Changed
search,search_with_mask, andprepareonTurboQuantIndexreturn empty results / are no-ops when called on a lazy uncommitted index, rather than panicking.
turbovec 0.4.0 (Python package) — 2026-05-17
turbovec — Python package (current: 0.3.0 → next: 0.4.0)
Added
-
Search-time filtering. Same feature surfaced as keyword-only arguments on
search:TurboQuantIndex.search(queries, k, *, mask=None)—maskis a NumPyboolarray of shape(len(idx),).IdMapIndex.search(queries, k, *, allowlist=None)—allowlistis a NumPyuint64array of external ids.- Pre-validates shape, dtype, emptiness and unknown ids and raises
ValueError/KeyErrorrather than letting the Rust panic surface aspyo3.PanicException. (#21)
-
Lazy construction.
TurboQuantIndex(dim=None, bit_width=4)andIdMapIndex(dim=None, bit_width=4)now accept an optionaldim. When omitted, the dim is inferred from the first.add(...)/.add_with_ids(...)call using the input array's shape. The framework integrations all rely on this internally now. -
.dimproperty on both index types now returnsint | None(wasint);Nonemeans the index hasn't seen its first add yet.
Changed
-
Haystack integration (
turbovec.haystack):TurboQuantDocumentStoreis now a structural drop-in forhaystack.document_stores.in_memory.InMemoryDocumentStore. Audited againsthaystack-ai 2.28.0and brought up to parity. In addition to the earlier filter-resolution fix:dimis now optional in the constructor; the index is built lazily on the firstwrite_documents.- Constructor accepts
embedding_similarity_function("cosine"default, since turbovec stores unit-normalized vectors),async_executor, andreturn_embeddingfor parity with the reference.scale_score=Truenow uses the right per-similarity-function formula ((s + 1) / 2for cosine,expit(s / 100)for dot product), fixing a pre-existing bug. - 12
*_asyncvariants added (count_documents_async,filter_documents_async,write_documents_async,delete_documents_async,delete_all_documents_async,update_by_filter_async,count_documents_by_filter_async,count_unique_metadata_by_filter_async,get_metadata_fields_info_async,get_metadata_field_min_max_async,get_metadata_field_unique_values_async,embedding_retrieval_async). - 8 utility methods added (
delete_all_documents,delete_by_filter,update_by_filter,count_documents_by_filter,count_unique_metadata_by_filter,get_metadata_fields_info,get_metadata_field_min_max,get_metadata_field_unique_values), plus astorageproperty andshutdown(). write_documentsnow validates its input and raisesValueError("Please provide a list of Documents.")on bad input instead of an opaqueAttributeError.- Persistence methods renamed to match the reference:
save → save_to_disk,load → load_from_disk. (No deprecation shims — pre-this-change persisted stores load fine, but the method names change.)
-
LangChain integration (
turbovec.langchain):TurboQuantVectorStoreis now a structural drop-in forlangchain_core.vectorstores.in_memory.InMemoryVectorStore. Audited againstlangchain_core 0.3.63. In addition to the earlier filter fixes:__init__no longer requires a pre-builtIdMapIndex. Lazy construction letsTurboQuantVectorStore(embedding)work directly — same no-arg ergonomics as the reference._select_relevance_score_fnoverride added — maps the raw cosine similarity into[0, 1]sosimilarity_search_with_relevance_scoresandas_retriever(search_type="similarity_score_threshold")work. Result is clamped to[0, 1]to absorb the small overshoot caused by quantization noise.get_by_ids/aget_by_idsimplemented from the side-car docstore.add_documentsoverrides the base-class default so partialDocument.idis honoured per-document (some ids explicit, others UUID-generated) instead of being dropped wholesale.- True async overrides:
aadd_documents,aadd_textsandasimilarity_search_with_scoreuseaembed_documents/aembed_queryfor genuine async embedding generation;asimilarity_search,asimilarity_search_by_vector,amax_marginal_relevance_search,afrom_texts,adeleteare explicit overrides too. deletenow returnsNone(wasbool) and is a no-op when called withids=None— matches the reference's contract.max_marginal_relevance_search/_by_vector/amax_marginal_relevance_searchraiseNotImplementedErrorwith a clear message rather than the base class's bareNotImplementedError. MMR isn't faithfully implementable on a quantized index because the algorithm requires full-precision candidate vectors that turbovec discards after encoding.- Persistence methods renamed:
save_local → dump,load_local → load, matching the reference.
-
LlamaIndex integration (
turbovec.llama_index):TurboQuantVectorStoreis now a structural drop-in forllama_index.core.vector_stores.simple.SimpleVectorStore. Audited againstllama_index.core 0.12.39. In addition to the earlier filter fixes:__init__no longer requires a pre-builtIdMapIndex;TurboQuantVectorStore()works directly.from_params(dim=None, bit_width=4)is also lazy.get_nodes(node_ids, filters)implemented (the reference raises NotImplementedError because it doesn't store nodes; we do).clear()resets state while preservingbit_width.to_dict/from_dictfor config round-trip.get(text_id)raisesNotImplementedErrorwith an explanation — we can't return the original embedding (quantized away).delete_nodes(node_ids, filters)now honoursfilters(previously raised). Both constraints intersect when supplied.- Async overrides for
async_add,adelete,adelete_nodes,aclear,aquery,aget_nodes. - StorageContext compatibility: new
from_persist_dir(persist_dir, namespace, fs)matching the reference's namespaced-filename convention, soStorageContext.from_defaults(persist_dir=...)works. Thepersist/from_persist_pathon-disk layout is now stem-based:persist_pathis a path stem and we write{stem}.tvim+{stem}.nodes.jsonnext to each other. This fits StorageContext's file-shaped paths and lets multiple namespaced stores share a directory.
-
JSON side-cars across all three integrations. Haystack, LangChain and LlamaIndex persistence now writes a plain-JSON side-car next to the binary
IdMapIndexpayload instead of a pickle. Theallow_dangerous_deserializationflag is gone everywhere — loading is safe regardless of file provenance. Document / node metadata must be JSON-serializable, which matches the constraint the reference in-tree stores already impose. The side-car carries aschema_versionfield; loaders reject unknown versions instead of silently misinterpreting bytes.