Add TurboQuant vector quantization algorithm - #354
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new TurboQuant vector quantization implementation to @workglow/util, exports it via the util schema entrypoint, and wires an optional “turbo” path into VectorQuantizeTask, along with a dedicated TurboQuant test suite.
Changes:
- Added
TurboQuantize.tsimplementing TurboQuant quantize/dequantize + similarity helpers and storage sizing utilities. - Exported TurboQuant APIs from
packages/util/src/schema-entry.ts. - Added
VectorQuantizeTaskinput options for selecting linear vs turbo behavior. - Added
TurboQuantizeunit tests.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
| packages/util/src/vector/TurboQuantize.ts | Implements TurboQuant quantize/dequantize + quantized similarity and storage helpers. |
| packages/util/src/schema-entry.ts | Re-exports TurboQuant APIs for public consumption via @workglow/util/schema. |
| packages/test/src/test/util/TurboQuantize.test.ts | Adds unit coverage for TurboQuant roundtrip, similarity estimates, determinism, and utilities. |
| packages/ai/src/task/VectorQuantizeTask.ts | Adds method selection and TurboQuant configuration to the vector quantization task. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| readonly bits: number; | ||
| /** Seed for deterministic random rotation. If omitted, uses a fixed default seed. */ | ||
| readonly seed: number | undefined; |
There was a problem hiding this comment.
TurboQuantizeOptions makes seed (and bits) required properties even though the implementation treats them as optional via defaults. This prevents callers from passing { bits: 4 } or {}. Make these fields optional (e.g., bits?: number; seed?: number) or provide a separate TurboQuantizeOptionsInput type that reflects the defaulting behavior.
| readonly bits: number; | |
| /** Seed for deterministic random rotation. If omitted, uses a fixed default seed. */ | |
| readonly seed: number | undefined; | |
| readonly bits?: number; | |
| /** Seed for deterministic random rotation. If omitted, uses a fixed default seed. */ | |
| readonly seed?: number; |
| function createPrng(seed: number): () => number { | ||
| let state = seed | 0 || 1; | ||
| return () => { | ||
| state ^= state << 13; | ||
| state ^= state >> 17; | ||
| state ^= state << 5; | ||
| // Convert to [0, 1) range | ||
| return (state >>> 0) / 4294967296; |
There was a problem hiding this comment.
createPrng coerces seed = 0 to state 1 (seed | 0 || 1), so a caller-provided seed of 0 will not be honored. Either document this explicitly or map 0 to a non-zero constant in a way that preserves the input seed’s determinism contract (e.g., hash/mix the seed instead of treating 0 specially).
| function randomRotate(values: Float64Array, seed: number): Float64Array { | ||
| const d = values.length; | ||
| // Pad to next power of 2 for Hadamard transform | ||
| const paddedLen = nextPowerOf2(d); | ||
| const result = new Float64Array(paddedLen); | ||
| result.set(values); | ||
|
|
||
| const prng = createPrng(seed); | ||
|
|
||
| // Apply 3 rounds for good mixing (standard practice for randomized Hadamard) | ||
| for (let round = 0; round < 3; round++) { | ||
| // Random sign flips (diagonal Rademacher matrix) | ||
| for (let i = 0; i < paddedLen; i++) { | ||
| if (prng() < 0.5) { | ||
| result[i] = -result[i]; | ||
| } | ||
| } | ||
|
|
||
| // Fast Walsh-Hadamard transform (in-place, normalized) | ||
| fastWalshHadamard(result); | ||
| } | ||
|
|
||
| // Return only the first d dimensions (drop padding) | ||
| return result.subarray(0, d); | ||
| } |
There was a problem hiding this comment.
The padding/truncation in randomRotate breaks orthogonality/invertibility for non-power-of-two dimensions: you rotate in paddedLen space but then drop the padded coordinates (subarray(0, d)). This loses information and means inverseRandomRotate() cannot correctly undo the rotation (and inner products won’t be preserved) for common dimensions like 768. Consider keeping/quantizing all paddedLen coordinates (and storing paddedLen in the result) or using an orthogonal transform that supports arbitrary lengths; alternatively, explicitly require power-of-two dimensions and throw otherwise.
| function unpackCodes(packed: Uint8Array, bits: number, count: number): number[] { | ||
| const codes: number[] = new Array(count); | ||
|
|
||
| let bitPos = 0; | ||
| for (let i = 0; i < count; i++) { | ||
| let code = 0; | ||
| let remaining = bits; | ||
| let shift = 0; | ||
| while (remaining > 0) { | ||
| const byteIdx = bitPos >> 3; | ||
| const bitOffset = bitPos & 7; | ||
| const bitsToRead = Math.min(remaining, 8 - bitOffset); | ||
| const mask = (1 << bitsToRead) - 1; | ||
| code |= ((packed[byteIdx] >> bitOffset) & mask) << shift; | ||
| shift += bitsToRead; | ||
| bitPos += bitsToRead; | ||
| remaining -= bitsToRead; | ||
| } | ||
| codes[i] = code; |
There was a problem hiding this comment.
unpackCodes() does not validate that packed.length is large enough for count * bits bits. If a truncated/invalid buffer is passed, typed-array out-of-bounds reads yield undefined which is coerced to 0, silently producing wrong codes (and potentially masking data corruption). Add an explicit length check (expected bytes = ceil(count * bits / 8)) and throw on mismatch.
| let quantized: TypedArray[]; | ||
|
|
||
| if (method === QuantizationMethod.TURBO) { | ||
| quantized = vectors.map((v) => { | ||
| const result = turboQuantize(v, { bits: turboBits, seed: turboSeed }); | ||
| return turboDequantize(result); | ||
| }); | ||
| } else { | ||
| quantized = vectors.map((v) => this.vectorQuantize(v, targetType, normalize)); | ||
| } |
There was a problem hiding this comment.
In the TURBO branch, the task returns turboDequantize(...) (a Float32Array) but still reports targetType as the requested type, and does not actually quantize to targetType. This is an observable mismatch (e.g., targetType: INT8 can return a Float32Array) and defeats the task’s “reduce storage” purpose. Either (1) change the output schema to return TurboQuant’s packed codes + metadata, (2) set targetType to FLOAT32 for the turbo path, and/or (3) post-process the dequantized vector through vectorQuantize(..., targetType, ...) if you intend turbo to be a preconditioning step.
| override async executeReactive(input: VectorQuantizeTaskInput): Promise<VectorQuantizeTaskOutput> { | ||
| const { vector, targetType, normalize = true } = input; | ||
| const { | ||
| vector, | ||
| targetType, | ||
| normalize = true, | ||
| method = QuantizationMethod.LINEAR, | ||
| turboBits = 4, | ||
| turboSeed = 42, | ||
| } = input; | ||
| const isArray = Array.isArray(vector); | ||
| const vectors = isArray ? vector : [vector]; | ||
| const originalType = this.getVectorType(vectors[0]); | ||
|
|
||
| const quantized = vectors.map((v) => this.vectorQuantize(v, targetType, normalize)); | ||
| let quantized: TypedArray[]; | ||
|
|
||
| if (method === QuantizationMethod.TURBO) { | ||
| quantized = vectors.map((v) => { | ||
| const result = turboQuantize(v, { bits: turboBits, seed: turboSeed }); | ||
| return turboDequantize(result); | ||
| }); | ||
| } else { | ||
| quantized = vectors.map((v) => this.vectorQuantize(v, targetType, normalize)); | ||
| } |
There was a problem hiding this comment.
TurboQuant support in VectorQuantizeTask isn’t covered by the existing VectorQuantizeTask tests (they only exercise the linear path). Add at least one test case that sets method: 'turbo' and asserts the returned type/metadata behavior you intend (and that it is deterministic for a fixed seed).
| /** | ||
| * Computes optimal quantization boundaries and reconstruction points for | ||
| * coordinates of a rotated unit vector. | ||
| * | ||
| * After random rotation, each coordinate of a d-dimensional unit vector follows | ||
| * approximately N(0, 1/d). For practical purposes with moderate dimensions (>50), | ||
| * we use uniform quantization over the range [-c/sqrt(d), c/sqrt(d)] where c | ||
| * controls the coverage (we use c ≈ 3 for 99.7% coverage). | ||
| */ | ||
| function getQuantizationParams( | ||
| bits: number, | ||
| dimensions: number | ||
| ): { readonly levels: number; readonly scale: number } { | ||
| const levels = 1 << bits; // 2^bits quantization levels | ||
| // After rotation, coordinates are approximately N(0, 1/d). | ||
| // Standard deviation is 1/sqrt(d). Cover ±3 standard deviations. | ||
| const coverage = 3.0; | ||
| const scale = coverage / Math.sqrt(dimensions); | ||
| return { levels, scale }; |
There was a problem hiding this comment.
The docstrings here describe “optimal quantization boundaries / Beta distribution” behavior, but getQuantizationParams() actually implements a fixed-range uniform quantizer (±3/√d) and doesn’t compute any boundaries or reconstruction points beyond a linear scale. Please align the documentation with the implemented quantizer, or implement the described non-uniform/optimal scalar quantization so callers aren’t misled about distortion guarantees.
| turboBits: { | ||
| type: "number", | ||
| title: "TurboQuant Bits", | ||
| description: | ||
| "Bits per dimension for TurboQuant method (1-8). Lower = more compression. 4 bits gives ~8x compression with near-lossless quality.", | ||
| default: 4, | ||
| minimum: 1, | ||
| maximum: 8, | ||
| }, | ||
| turboSeed: { | ||
| type: "number", | ||
| title: "TurboQuant Seed", | ||
| description: | ||
| "Seed for the random rotation in TurboQuant. All vectors in the same collection must use the same seed for similarity search to work.", | ||
| default: 42, | ||
| }, |
There was a problem hiding this comment.
turboBits and turboSeed are modeled as type: "number" in the input schema, but the underlying implementation treats bits as an integer (and bitwise PRNG logic will effectively truncate non-integer seeds). Consider using type: "integer" (or multipleOf: 1) for these fields to match runtime expectations and fail validation earlier.
|
@copilot apply changes based on the comments in this thread |
Applied all 8 review changes in commit 051f0b7:
|
9e9875f to
48ad905
Compare
447c7b0 to
d7ec70d
Compare
|
@claude rebase on main |
d7ec70d to
d031344
Compare
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||
…-fixes-p4d8qo fix(util,ai): correct TurboQuant quantization grid and harden its decode path (follow-up to #354)
…ode path Follow-up to #354, which introduced `TurboQuantize.ts`. The module is unreleased, so the encoded format changes here affect no persisted data. The quantization grid used a clipping range fixed at 3 standard deviations for every bit width. That is only near-optimal around 4 bits, and wrong in both directions elsewhere: - At 1 bit the two reconstruction points sat at +/-3 sigma, so a reconstruction came back exactly 3.0x too long and `turboQuantizedCosineSimilarity(q, q)` returned 9.0 from a function documented to return [-1, 1]. - From 6 bits up, the bits-independent clipping error dominated everything the extra levels bought. Measured relative L2 at d=1024 was 0.0429 / 0.0356 / 0.0336 at 6 / 7 / 8 bits: four times the levels for a 22% gain. The clipping range is now the MSE-optimal loading factor for a unit-variance Gaussian at each bit width, tabulated from Max (1960) and solved numerically for the level counts the typed-array path uses (255 for int8, 65535 for int16), which are never powers of two. Both call sites read the same helper rather than repeating a literal. Reconstruction is additionally renormalized to the recorded L2 norm, and the similarity helpers divide by each reconstruction's own norm, so cosine is an actual cosine: self-similarity is exactly 1 and the documented range holds by construction. Measured at d=1024, seed 42 (relative L2 by bit width, before -> after): 1 bit 2.2825 -> 0.6351 5 bits 0.0647 -> 0.0648 2 bits 0.5881 -> 0.3474 6 bits 0.0429 -> 0.0382 3 bits 0.2496 -> 0.1889 7 bits 0.0356 -> 0.0213 4 bits 0.1229 -> 0.1099 8 bits 0.0336 -> 0.0098 Every step now improves by at least 15%, which is what the new monotonicity assertion pins; per-bit ceilings alone would not have caught the flat tail. Mean absolute inner-product error at d=1024 improves 0.0566 -> 0.0252. `turboQuantizeToTypedArray` kept only the first `d` of `nextPowerOf2(d)` rotated coordinates, making it a lossy random projection whenever `d` was not a power of two -- measurably worse than the plain linear quantizer it was documented to beat (int8 cosine RMSE vs linear: 0.0164 vs 0.0027 at d=768, 0.0126 vs 0.0033 at d=1536, 0.0094 vs 0.0026 at d=3072; it wins only at d=1024). Those are MiniLM, text-embedding-3-small and text-embedding-3-large. It now rejects a non-power-of-2 length, with `{ padToPowerOf2: true }` to opt into a longer result instead (padding d=768 measures 0.00034 RMSE against 0.01639 for cropping). `VectorQuantizeTask` surfaces the rejection with both remedies named, since from a task caller's seat the underlying throw reads as a bug. `turboQuantize` is unaffected: it keeps all padded coordinates and stays invertible at any size. Decode-path hardening: - `assertQuantizeResultShape` rejects a `codes` field that is not a `Uint8Array` before any other check. `TurboQuantizeResult` is a plain serializable record, so the obvious way to persist one is JSON -- which turns `codes` into an object with no usable `length`. Since `undefined < expectedBytes` is false, the existing size guard waved it through and every byte decoded as NaN -> code 0, producing a confident vector of garbage rather than an error. - Seeds are validated on encode, not only on decode. A non-integer seed previously encoded successfully and then failed to decode, which is data written and permanently unreadable. Seeds outside the int32 window are also rejected: `2**32 + 1` silently aliased onto `1`. - Records carry `version: 1`, checked on every decode, so the next grid change fails loudly instead of mis-scaling silently. `MAX_TURBO_DIMENSIONS` drops from 2^24 to 2^20. Its justification cited a single 128 MB buffer, but peak RSS growth measures 32 MB at d=2^20 (~32 bytes per padded coordinate, several working buffers), so the old cap allowed far more than advertised. The sign-table cache is now bounded by bytes rather than entry count -- 16 entries at the old maximum retained hundreds of megabytes -- and `clearSignTableCache()` is exported, since the cache is process-global and had no release path. Verified: 12 distinct 3 MB tables retain 6.00 MB against the 8 MB budget, and eviction is transparent (a recomputed table reproduces codes exactly). The header doc claimed "optimal per-coordinate scalar quantization", coordinates concentrating around a Beta distribution, and "within ~2.7x of theoretical distortion limit at all bit-widths", while `getQuantizationParams` conceded 280 lines away that no distribution-fitted quantization happens. It now states what the module actually does and what it leaves unimplemented. Golden-byte fixtures are re-pinned to the new grid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6huUY7hSkRbjun1P9HKsz
0278919 to
e677fa8
Compare
…ode path Follow-up to #354, which introduced `TurboQuantize.ts`. The module is unreleased, so the encoded format changes here affect no persisted data. The quantization grid used a clipping range fixed at 3 standard deviations for every bit width. That is only near-optimal around 4 bits, and wrong in both directions elsewhere: - At 1 bit the two reconstruction points sat at +/-3 sigma, so a reconstruction came back exactly 3.0x too long and `turboQuantizedCosineSimilarity(q, q)` returned 9.0 from a function documented to return [-1, 1]. - From 6 bits up, the bits-independent clipping error dominated everything the extra levels bought. Measured relative L2 at d=1024 was 0.0429 / 0.0356 / 0.0336 at 6 / 7 / 8 bits: four times the levels for a 22% gain. The clipping range is now the MSE-optimal loading factor for a unit-variance Gaussian at each bit width, tabulated from Max (1960) and solved numerically for the level counts the typed-array path uses (255 for int8, 65535 for int16), which are never powers of two. Both call sites read the same helper rather than repeating a literal. Reconstruction is additionally renormalized to the recorded L2 norm, and the similarity helpers divide by each reconstruction's own norm, so cosine is an actual cosine: self-similarity is exactly 1 and the documented range holds by construction. Measured at d=1024, seed 42 (relative L2 by bit width, before -> after): 1 bit 2.2825 -> 0.6351 5 bits 0.0647 -> 0.0648 2 bits 0.5881 -> 0.3474 6 bits 0.0429 -> 0.0382 3 bits 0.2496 -> 0.1889 7 bits 0.0356 -> 0.0213 4 bits 0.1229 -> 0.1099 8 bits 0.0336 -> 0.0098 Every step now improves by at least 15%, which is what the new monotonicity assertion pins; per-bit ceilings alone would not have caught the flat tail. Mean absolute inner-product error at d=1024 improves 0.0566 -> 0.0252. `turboQuantizeToTypedArray` kept only the first `d` of `nextPowerOf2(d)` rotated coordinates, making it a lossy random projection whenever `d` was not a power of two -- measurably worse than the plain linear quantizer it was documented to beat (int8 cosine RMSE vs linear: 0.0164 vs 0.0027 at d=768, 0.0126 vs 0.0033 at d=1536, 0.0094 vs 0.0026 at d=3072; it wins only at d=1024). Those are MiniLM, text-embedding-3-small and text-embedding-3-large. It now rejects a non-power-of-2 length, with `{ padToPowerOf2: true }` to opt into a longer result instead (padding d=768 measures 0.00034 RMSE against 0.01639 for cropping). `VectorQuantizeTask` surfaces the rejection with both remedies named, since from a task caller's seat the underlying throw reads as a bug. `turboQuantize` is unaffected: it keeps all padded coordinates and stays invertible at any size. Decode-path hardening: - `assertQuantizeResultShape` rejects a `codes` field that is not a `Uint8Array` before any other check. `TurboQuantizeResult` is a plain serializable record, so the obvious way to persist one is JSON -- which turns `codes` into an object with no usable `length`. Since `undefined < expectedBytes` is false, the existing size guard waved it through and every byte decoded as NaN -> code 0, producing a confident vector of garbage rather than an error. - Seeds are validated on encode, not only on decode. A non-integer seed previously encoded successfully and then failed to decode, which is data written and permanently unreadable. Seeds outside the int32 window are also rejected: `2**32 + 1` silently aliased onto `1`. - Records carry `version: 1`, checked on every decode, so the next grid change fails loudly instead of mis-scaling silently. `MAX_TURBO_DIMENSIONS` drops from 2^24 to 2^20. Its justification cited a single 128 MB buffer, but peak RSS growth measures 32 MB at d=2^20 (~32 bytes per padded coordinate, several working buffers), so the old cap allowed far more than advertised. The sign-table cache is now bounded by bytes rather than entry count -- 16 entries at the old maximum retained hundreds of megabytes -- and `clearSignTableCache()` is exported, since the cache is process-global and had no release path. Verified: 12 distinct 3 MB tables retain 6.00 MB against the 8 MB budget, and eviction is transparent (a recomputed table reproduces codes exactly). The header doc claimed "optimal per-coordinate scalar quantization", coordinates concentrating around a Beta distribution, and "within ~2.7x of theoretical distortion limit at all bit-widths", while `getQuantizationParams` conceded 280 lines away that no distribution-fitted quantization happens. It now states what the module actually does and what it leaves unimplemented. Golden-byte fixtures are re-pinned to the new grid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6huUY7hSkRbjun1P9HKsz
e677fa8 to
21c1db6
Compare
Implement near-optimal vector quantization based on "TurboQuant: Online Vector Quantization with Near-optimal Distortion Rate" (Zandieh et al., 2025). The algorithm uses randomized Walsh-Hadamard rotation + optimal per-coordinate scalar quantization to achieve ~2.7x of theoretical distortion limits. Data-oblivious and per-vector, making it ideal for streaming RAG pipelines. - Add turboQuantize/turboDequantize in @workglow/util/schema - Add turboQuantizedInnerProduct/turboQuantizedCosineSimilarity for direct similarity on quantized vectors - Extend VectorQuantizeTask with "turbo" method option and turboBits/ turboSeed parameters - Add 29 tests covering roundtrip quality, compression, and similarity https://claude.ai/code/session_01YD75mdbcw6ygET7hdjQdWD
…tible output TurboQuant's rotation + optimal scalar quantization now outputs directly into byte-aligned TypedArrays (Int8Array, Uint8Array, Int16Array, Uint16Array) with the same .length as the input vector. This means the output works transparently with all existing storage backends and cosineSimilarity search — no dimensional mismatch. - Add turboQuantizeToTypedArray() that rotates then quantizes into the target integer type at its native bit width - Update VectorQuantizeTask turbo branch to call turboQuantizeToTypedArray directly instead of quantize+dequantize roundtrip - Remove turboBits parameter (bit width determined by targetType) - Add 14 tests for the new function covering type output, similarity preservation, determinism, range bounds, and edge cases https://claude.ai/code/session_01YD75mdbcw6ygET7hdjQdWD
These files predate #683's eslint/prettier config changes and had never been run through the formatter.
…idation (#713) Review fixes stacking on the TurboQuant integration branch. CRITICAL: turboQuantizeToTypedArray's unsigned branch mapped x -> (x + scale) / (2 * scale) * max, an affine map whose DC offset (127.5 for uint8) lands on every stored coordinate. Cosine similarity is invariant to scaling but not to translation, so that shared component dominates: at d=1024 / uint8 / seed 42 a true cosine of 0.0139 reads 0.9018, and across 40 random pairs the whole range collapses to [0.893, 0.907] — negatives become impossible and absolute thresholds meaningless. The offset cannot be threaded back out (cosineSimilarity takes only the two arrays, and pgvector / SQLite / DuckDB compute distance server-side), and unsigned buys no storage over the signed type of the same width. Nothing has shipped on main, so uint8/uint16 are now rejected outright rather than silently corrupting rankings. Also fixed: - prototype-chain target names ("constructor", "__proto__") resolved an inherited Object.prototype value and slipped past the `if (!range)` guard, returning an all-zero Uint16Array; now guarded with Object.hasOwn before indexing. - NaN / Infinity input produced a non-finite norm, failed `norm > 0`, and returned the freshly allocated all-zero buffer with no error. Extracted normalizeToUnit() and made it throw. - nextPowerOf2 used `p <<= 1`, a 32-bit signed shift that wraps to 0 at 2^30 and loops forever; turboQuantizeStorageBytes(2**30 + 1, 4) hung the event loop permanently. Now `p *= 2`, with assertDimensions (integer, 1..2^24) and assertBits (integer 1..8) validating both exported helpers. - fastWalshHadamard assumed a power-of-2 length and read past the buffer otherwise, so a TurboQuantizeResult carrying paddedDimensions: 6 decoded to all-NaN silently. The transform now enforces the invariant, and turboDequantize / turboQuantizedInnerProduct re-validate bits, dimensions, seed, norm and paddedDimensions before use — that record is a plain serializable interface intended for storage. - inverseRandomRotate rebuilt a 3 x paddedLen boxed boolean[][] on every dequantize; sign masks are now built once by getSignTable and memoized in a bounded (16-entry, oldest-evicted) cache. unpackCodes returns a Uint8Array instead of a boxed number[], so turboQuantizedInnerProduct no longer allocates two 1024-element JS arrays per comparison. Output is unchanged. - VectorQuantizeTaskOutput now records `method` and `turboSeed`, so a consumer can tell a rotated Int8Array from a linear-quantized one. Nothing downstream could previously detect a collection re-indexed with a different seed or mixed with method: "linear"; both produce garbage rankings with no error. The turbo branch also rejects non-signed targetType early. - Documented (not "fixed") the fixed-length-output projection: randomRotate produces nextPowerOf2(d) coordinates but only the first d are kept, so a non-power-of-2 d is a random projection, not an orthogonal rotation. Measured int8 cosine RMSE (seed 42, 40 pairs): d=1024 -> 0.001, d=1000 -> 0.006, d=1536 -> 0.013, d=768 -> 0.019. - Conventions: license year 2026 on the two files created in 2026, readonly `T | undefined` on TurboQuantizeOptions, and `>>> 17` in the xorshift32 PRNG (which is why the golden byte literals are what they are). Tests: unsigned/float/prototype-chain rejection, DC-offset (zero-centred signed output), NaN/Infinity, out-of-range dimensions and bits, tampered paddedDimensions, cross-dimension cosine fidelity, hardcoded golden bytes for cross-process determinism, and output method/turboSeed reporting. All use a local deterministic PRNG, never Math.random. Co-authored-by: Claude <noreply@anthropic.com>
…ode path Follow-up to #354, which introduced `TurboQuantize.ts`. The module is unreleased, so the encoded format changes here affect no persisted data. The quantization grid used a clipping range fixed at 3 standard deviations for every bit width. That is only near-optimal around 4 bits, and wrong in both directions elsewhere: - At 1 bit the two reconstruction points sat at +/-3 sigma, so a reconstruction came back exactly 3.0x too long and `turboQuantizedCosineSimilarity(q, q)` returned 9.0 from a function documented to return [-1, 1]. - From 6 bits up, the bits-independent clipping error dominated everything the extra levels bought. Measured relative L2 at d=1024 was 0.0429 / 0.0356 / 0.0336 at 6 / 7 / 8 bits: four times the levels for a 22% gain. The clipping range is now the MSE-optimal loading factor for a unit-variance Gaussian at each bit width, tabulated from Max (1960) and solved numerically for the level counts the typed-array path uses (255 for int8, 65535 for int16), which are never powers of two. Both call sites read the same helper rather than repeating a literal. Reconstruction is additionally renormalized to the recorded L2 norm, and the similarity helpers divide by each reconstruction's own norm, so cosine is an actual cosine: self-similarity is exactly 1 and the documented range holds by construction. Measured at d=1024, seed 42 (relative L2 by bit width, before -> after): 1 bit 2.2825 -> 0.6351 5 bits 0.0647 -> 0.0648 2 bits 0.5881 -> 0.3474 6 bits 0.0429 -> 0.0382 3 bits 0.2496 -> 0.1889 7 bits 0.0356 -> 0.0213 4 bits 0.1229 -> 0.1099 8 bits 0.0336 -> 0.0098 Every step now improves by at least 15%, which is what the new monotonicity assertion pins; per-bit ceilings alone would not have caught the flat tail. Mean absolute inner-product error at d=1024 improves 0.0566 -> 0.0252. `turboQuantizeToTypedArray` kept only the first `d` of `nextPowerOf2(d)` rotated coordinates, making it a lossy random projection whenever `d` was not a power of two -- measurably worse than the plain linear quantizer it was documented to beat (int8 cosine RMSE vs linear: 0.0164 vs 0.0027 at d=768, 0.0126 vs 0.0033 at d=1536, 0.0094 vs 0.0026 at d=3072; it wins only at d=1024). Those are MiniLM, text-embedding-3-small and text-embedding-3-large. It now rejects a non-power-of-2 length, with `{ padToPowerOf2: true }` to opt into a longer result instead (padding d=768 measures 0.00034 RMSE against 0.01639 for cropping). `VectorQuantizeTask` surfaces the rejection with both remedies named, since from a task caller's seat the underlying throw reads as a bug. `turboQuantize` is unaffected: it keeps all padded coordinates and stays invertible at any size. Decode-path hardening: - `assertQuantizeResultShape` rejects a `codes` field that is not a `Uint8Array` before any other check. `TurboQuantizeResult` is a plain serializable record, so the obvious way to persist one is JSON -- which turns `codes` into an object with no usable `length`. Since `undefined < expectedBytes` is false, the existing size guard waved it through and every byte decoded as NaN -> code 0, producing a confident vector of garbage rather than an error. - Seeds are validated on encode, not only on decode. A non-integer seed previously encoded successfully and then failed to decode, which is data written and permanently unreadable. Seeds outside the int32 window are also rejected: `2**32 + 1` silently aliased onto `1`. - Records carry `version: 1`, checked on every decode, so the next grid change fails loudly instead of mis-scaling silently. `MAX_TURBO_DIMENSIONS` drops from 2^24 to 2^20. Its justification cited a single 128 MB buffer, but peak RSS growth measures 32 MB at d=2^20 (~32 bytes per padded coordinate, several working buffers), so the old cap allowed far more than advertised. The sign-table cache is now bounded by bytes rather than entry count -- 16 entries at the old maximum retained hundreds of megabytes -- and `clearSignTableCache()` is exported, since the cache is process-global and had no release path. Verified: 12 distinct 3 MB tables retain 6.00 MB against the 8 MB budget, and eviction is transparent (a recomputed table reproduces codes exactly). The header doc claimed "optimal per-coordinate scalar quantization", coordinates concentrating around a Beta distribution, and "within ~2.7x of theoretical distortion limit at all bit-widths", while `getQuantizationParams` conceded 280 lines away that no distribution-fitted quantization happens. It now states what the module actually does and what it leaves unimplemented. Golden-byte fixtures are re-pinned to the new grid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6huUY7hSkRbjun1P9HKsz
…ld not fail (#757) * fix(util): scale the TurboQuant norm so extreme vectors survive quantization `normalizeToUnit` accumulated `sumSquares += v * v` on raw coordinates, which squares the input's exponent: the running sum left the double range long before the vector did. Above ~1e154 it overflowed to Infinity, so a finite input was rejected as "containing NaN or Infinity"; below ~1e-162 it underflowed to 0, so a finite input was recorded as a zero vector, decoded to all zeroes and scored 0 against itself. Replaced with a max-scaled two-pass norm. The per-element finiteness check moved into pass 1 and is load-bearing under max-scaling: an Infinity input would otherwise set maxAbs = Infinity and every scaled coordinate would become NaN. The final division is by maxAbs and then by rootS, never by their product, which is what keeps subnormal inputs alive. Also hardened the decode path: - `turboDequantize` rejects a norm above the Float32 maximum. The output is a Float32Array whose L2 norm IS `norm`, so an out-of-range one decoded to all-Infinity silently. The check is on the recorded scalar, so it is O(1) and confined to the decode — cosine similarity is scale-free and keeps working on such records. - `assertQuantizeResultShape` rejects a negative norm. It is always a Math.sqrt result, and the decode multiplies by it, so a negative one sign-flipped the whole reconstruction with nothing reported. TURBO_QUANTIZE_VERSION stays at 1: the code-to-value mapping is untouched. [1,2,3,4] still records norm === Math.sqrt(30), codes [175,104,163,116] and a bit-identical decode, and both golden-byte vectors are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomTUtZSTgUbFCYqFs4pcu * test(util): make the TurboQuantize suite runnable and give three tests teeth The suite imported `getTestingLogger` from `../../binding/TestingLogger`, which does not exist, so the file failed to load and NONE of its 66 tests ran. Fixed to `@workglow/util/test`, matching every other test in the package. With the suite running, three of its tests could not fail: - the two magnitude tests: `turboDequantize` unconditionally rescales by `norm / croppedNorm`, so the magnitude ratio is exactly 1 for every input, bit width and grid; - the self-similarity test: `quantizedCosine` divides by each side's own `codeNorm`, so a record scores 1 against itself by algebra. Verified by swapping the loading-factor table for a fixed 3-sigma array: all three stayed green while the grid regression they appear to guard was live. Replaced with measurements that move: - magnitude folded into the existing relative-L2 test as a one-line invariant, plus a new cropped (d=768) relative-L2 test with per-bit ceilings and a `relativeL2[i+1] < relativeL2[i] * 0.85` step. The cropped path renormalizes against the first 768 of 1024 coordinates, so the padded test does not cover it. Ceilings measured on both grids; the 3-sigma row is BETTER at 6-8 bits there, so the step assertion alone passes it and the 2/3-bit ceilings are what reject it. - self-similarity replaced by RMSE of (quantized cosine - exact cosine) over 24 seeded pairs at d=1024, per bit width, against ceilings with >=21% headroom on the shipped grid that reject the 3-sigma one at 2, 3 and 8 bits. The `<= 1.0` self-similarity line survives as one line of the existing range test. The "higher dimensions" test additionally used `Math.random()`. It is now seeded, but NOT with the `sim256 > sim64` assertion its name implied: measured over 200 seeded draws at 4 bits, mean reconstruction cosine is 0.99496 at d=64 versus 0.99441 at d=256 — slightly worse, with d=256 winning only 63 of 200. What does improve with dimension is pairwise similarity-estimation error (RMSE 0.0186 / 0.0101 / 0.0047 at d=64/256/1024), so that is what it now asserts and what it is now named for. Also pins the norm fix: overflow, underflow, the Float32 decode ceiling, the negative-norm guard, and a bit-identical [1,2,3,4] round-trip. Re-verified under the 3-sigma grid: 6 tests now fail where 3 did before, and all three replacements are among them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomTUtZSTgUbFCYqFs4pcu --------- Co-authored-by: Claude <noreply@anthropic.com>
…its guidance (#762) `executePreview` hard-rejected every non-power-of-2 dimensionality for `method: "turbo"` and never exposed `turboQuantizeToTypedArray`'s `{ padToPowerOf2: true }`, so the accurate option was unreachable from the task. Worse, the rejection message quoted RMSE figures for the CROPPED variant the util no longer emits, and used them to steer users to `linear` — advice that is backwards for the option now on offer. Re-measured over 40 seeded pairs at int8, padded turbo vs this task's linear path: 0.00034 vs 0.00269 at d=768, 0.00024 vs 0.00331 at d=1536, 0.00021 vs 0.00263 at d=3072. Padded turbo is 7.8x, 13.8x and 12.8x more accurate at exactly the three sizes the old message named as reasons to avoid it. - Adds a `turboPadToPowerOf2` input, DEFAULT FALSE. Enabling it lengthens the output vector (d -> nextPowerOf2(d)) and a storage column sized to d would reject the result, so it has to be opted into rather than inferred. - Rewrites the rejection message to lead with the flag, state the widening and the column sizing, quote the real measurement, and name `linear` second as the option that preserves length. Same correction applied to the `method` description and to the util's JSDoc and throw, where the cropped figures are now explicitly labelled as the cropped variant's and marked as not to be quoted as the cost of turbo. - Folds the duplicated `nextPowerOf2`: the util's copy calls `assertDimensions` first (rejecting non-integers, n < 1, n > 2**20) and the task's local copy did neither, so an oversized vector bypassed the task's carefully worded error and surfaced the low-level one. The util's helper is now exported and the local copy deleted. Tests: a 768-dim vector with `turboPadToPowerOf2: true` returns length 1024 and beats `method: "linear"` on measured RMSE over seeded pairs; the rejection test additionally asserts the message names `turboPadToPowerOf2` and no longer contains the stale 0.0164 figure. All three were confirmed RED against the base branch before the fix. Claude-Session: https://claude.ai/code/session_01RomTUtZSTgUbFCYqFs4pcu Co-authored-by: Claude <noreply@anthropic.com>
…rently red) The accuracy cases here all draw both vectors i.i.d. uniform at d=1024, where the true cosine concentrates at 0 ± ~0.03 — the one regime where the estimator's bias vanishes. The committed RMSE ceilings (0.033 @1bit, 0.016 @2bit) therefore cannot see it, and the module header claims the similarity estimates are unbiased. This measures MEAN SIGNED error on CORRELATED pairs (`b = t*a + sqrt(1-t^2)*noise`, t in {0.5, 0.8, 0.95}, 32 seeded pairs per cell). Signed rather than RMSE deliberately: RMSE scores a uniform 0.08 under-report and 0.08 of symmetric noise identically, and only the first silently moves an absolute threshold. The reference is `cosineSimilarity(a, b)` on the unquantized inputs, never `t` — the realized cosine of a finite sample differs from `t` by ~1/sqrt(d), and scoring against `t` would fold that sampling error into the measured bias. Bands are the measured value ±30%, floored at ±0.005, and two-sided: this is a record of behavior, not a ceiling. It FAILS as committed, at 1 bit, by design — the next commit is the fix. Every similar pair is under-reported: true 0.80 reads 0.59 at 1 bit (-0.208), 0.72 at 2 bits (-0.080), 0.78 at 3 (-0.027). A caller storing 2-bit codes for the advertised 16x compression and keeping hits above cos > 0.8 drops matches whose real cosine is 0.87.
At 1 bit the grid is exactly ±scale, so both reconstructions are constant-magnitude sign vectors and the normalized dot product reduces to the fraction of coordinates whose signs agree. Under a random rotation that converges to `1 - 2*theta/pi` — a DIFFERENT function of the angle, not a noisy cosine. It reads a true 0.80 as 0.59 and a true 0.95 as 0.79. That one is exactly invertible, so `quantizedCosine` inverts it after the clamp (`cos(pi*(1 - r)/2)`, the argument therefore always a valid angle). One `Math.cos` per comparison, no change to any stored byte, no version bump — the golden encodings are untouched. Deliberately NOT extended to 2-8 bits. The shrinkage there is ordinary quantization error with no closed form, and a fitted per-bit gain would be a constant tuned at one dimensionality while the shrinkage varies with d — it would correct one corpus and skew the next. The header documents it instead, with the measured per-bit table, and says plainly that ranking survives the bias while absolute thresholds and calibration do not. Header corrections in the same pass: the claim that renormalization keeps "the similarity estimates unbiased" was false (magnitude is unbiased; similarity is not), the citation gains arXiv:2504.19874, and the first line now states that the name refers to the borrowed rotation strategy — not the paper's quantizer or its distortion bound. THE TRADE, stated because it is a real one. The inversion amplifies whatever noise the sign statistic carries by up to ~1.57x near theta = pi/2, and the existing "should track the exact cosine within a per-bit-width RMSE ceiling" case draws i.i.d. uniform pairs at d=1024, which land exactly there: it pays the amplification in full and collects none of the bias correction. Its 1-bit RMSE went 0.0273 -> 0.0356 and the ceiling follows, 0.033 -> 0.050, with the reasoning recorded in the test rather than the number quietly moved. Unbiased-but-noisier is the right side of that trade: the noise is symmetric and averages out over a candidate set, and it is worst precisely where the answer is "unrelated" either way, while the bias moved every absolute threshold in one direction. A new case pins the resulting asymmetry: the correction lives in `quantizedCosine`, so `turboDequantize` + plain `cosineSimilarity` is NOT angle-corrected at 1 bit. The two routes look interchangeable at every other bit width, and a caller who decodes once to compare many times would otherwise silently fall back to the biased estimator at the bit width where the bias is largest.
…802) The module ships two independent encoders and nothing said so. `turboQuantizeToTypedArray` returns a plain Int8Array/Int16Array and drops into any IVectorStorage backend; the four packed-codec functions return a `TurboQuantizeResult` that no backend in this repo can accept, and none could without a new column type. Verified: `git grep` for the six packed-codec names and `TurboQuantizeResult` across the branch, excluding the module and its own test, returns zero hits. And the storage layer cannot take the shape even if a caller wanted to — `assertVectorShape` (called by every backend on write and query) requires an array-like whose length equals the declared dimensionality with every entry a finite number, which a record fails outright and whose packed `codes` buffer fails too (at 4 bits it holds two coordinates per byte). The backends that score in-process all call `cosineSimilarity` on raw numbers with no hook for `turboQuantizedCosineSimilarity`, and the pgvector-backed ones compute the distance server-side where no client-side scorer can be injected at all. Nothing is un-exported and no storage is wired. Un-exporting was considered and rejected: the ~1200-line test suite imports those names from `@workglow/util/schema`, so it would force a cross-package deep import into `packages/util/src` (which nothing in `packages/test` does) or the deletion of verified-correct code and its tests. Wiring storage needs a new column type, a client-side scoring path, and per-backend fallbacks for the server-side-distance engines — a multi-package feature, not a review fix. So the fix is documentation: a module-header section naming which encoder has a storage path, why the other cannot have one, what the packed record IS good for (in-process comparison — a candidate cache, a client-side rerank over a shortlist), and an explicit "do not persist it expecting a retrieval path to exist". The real integration is tracked in #798, linked from the section. Claude-Session: https://claude.ai/code/session_01UW1Qr5mxetAQr61YKEY9nz Co-authored-by: Claude <noreply@anthropic.com>
* fix(util): correct TurboQuant's cosine shrinkage at 2-4 bits `quantizedCosine` corrected only the 1-bit case, on the stated grounds that a 2-8 bit correction "would be a constant tuned at one dimensionality, and the shrinkage varies with d". Both halves are false. It is not a constant: the shrinkage varies with the cosine itself (4.5% between rho=0.5 and rho=0.95 at 2 bits), which is why a scalar gain will not do and a map is needed. And it does not vary with d: `getQuantizationParams` divides the loading factor by sqrt(paddedLen), so the standardized grid is identical at every dimensionality. Measured across a 32x range of d the ratio moves <=1.2%. The quantity the estimator returns is E[Q(x)Q(y)]/E[Q(x)^2] for a standard bivariate normal pair. `shrinkageAt` evaluates it by 1-D quadrature over the conditional expectation E[Q(y)|x] -- a sum of normal CDFs, smooth, so Simpson within each quantization bin resolves it -- and `invertShrinkage` inverts a 65-knot table of it by binary search. At 1 bit the same machinery reproduces the Goemans-Williamson closed form the module already ships, to 2.8e-4, which is what pins the quadrature. Measured mean signed error, d=1024, 32 correlated pairs, rho=0.8: 2 bits -0.0799 -> -0.0013 3 bits -0.0271 -> -0.0009 4 bits -0.0075 -> +0.0009 5-8 bits are left alone: the residual bias is already at or below the estimator's own noise (0.0030 vs 0.0034 at 5 bits) and the table build scales as levels^2 (555 ms at 8 bits vs 22 ms at 4). The cost is 1/g'(0) noise amplification near orthogonality: 1.13x at 2 bits against the 1.57x the module already pays at 1 bit. RMSE ceilings at 2 and 3 bits follow, 0.016 -> 0.019 and 0.0085 -> 0.0090. No stored bytes change and TURBO_QUANTIZE_VERSION is untouched -- the code-to-value mapping is identical; only the estimator's final inversion moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UW1Qr5mxetAQr61YKEY9nz * perf(util): add a prepared-query path for TurboQuant similarity search (#804) `turboQuantizedCosineSimilarity` and `turboQuantizedInnerProduct` decode BOTH sides on every call, so scoring one query against a candidate set unpacks and reconstructs the query once per candidate -- at d=1024 and 20,000 candidates, 80,000 arrays and ~352 MB of churn, roughly half of it re-deriving coordinates that never changed. The docstring said "without full dequantization", which is true of the inverse rotation and false of the decode, and read as a licence to use it in a search loop. `turboPrepareQuery` decodes the query once; `turboPreparedCosineSimilarity` decodes only the candidate. Measured over that sweep: 230 ms -> 105 ms at 8 bits (2.1x) and 180 ms -> 110 ms at 4 bits (1.6x). The corrected widths gain less because the shrinkage inversion is a per-candidate cost neither path can hoist. The prepared path returns BIT-FOR-BIT what the pairwise helper returns, at every width 1-8, and that is the contract the tests pin with `toBe`. Two things make it hold. The shrinkage/angle correction is factored into a shared `finishCosine`, so the two entry points cannot grow separate copies of it. And the prepared query keeps its `codeNorm` SEPARATE rather than pre-dividing its coordinates: pre-dividing turns `dot / (normA * normB)` into `sum((a_i/normA) * b_i) / normB`, a different floating-point expression that disagrees in the last bits on 96% of random pairs at d=1024. Numerically that is nothing; as a contract it is everything, because it would mean adopting the faster path silently moves every score in an index. Keeping the norm separate costs one multiply per candidate against 1024 multiply-adds -- unmeasurable (93 ms exact vs 96 ms pre-divided). Compatibility is re-checked per candidate with the same three checks and the same messages as `assertComparablePair`: a prepared query is a plain object a caller can hold across a heterogeneous index, and the dot product would happily consume a candidate from another rotation basis and return a number. Claude-Session: https://claude.ai/code/session_01UW1Qr5mxetAQr61YKEY9nz Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
… int8 baseline (#801) The "8x more accurate than linear" claim was measured against a defective baseline. `VectorQuantizeTask`'s linear int8 path divides by the vector's L2 norm before scaling by 127, so on a well-spread vector every coordinate lands near 1/sqrt(d) and the largest code it emits is 8 at d=768, 6 at d=1536, 4 at d=3072 — three of its eight bits are gone before any comparison happens. Beating it measured that defect, not the rotation. Against a max-abs int8 quantizer (divide by max|v|, use the full code range), padded turbo is 1.4x-1.8x WORSE on well-conditioned inputs. What the rotation actually buys is independence from the input distribution: turbo stays in a 0.0001-0.0004 band whatever the input looks like, while max-abs swings with the tail — on Gaussian vectors with 2 dimensions at 20x ("massive activations"), turbo 0.00039 vs max-abs 0.00174 at d=768. The module doc, the two rejection messages and the task's `method` schema description now carry all three columns and say which is which. The cropping paragraph keeps its figures but is re-anchored to padded turbo and max-abs rather than to the L2 path. `quantizeToInt8` itself is deliberately NOT repaired here — see #796. The divisor change rescales every stored coordinate ~16x at d=768; cosine survives it (positive scalar multiple) but `l2` and `ip` do not, and a stored int8 vector carries no marker of which scaling produced it. That is a migration-relevant change and does not belong in an "add TurboQuant" PR. Also in this change: - `optimalLoadingFactor`'s "within 0.35%" was the 16-level figure quoted as if it were the worst; the real curve runs 4.03% at 2 levels down to ~0.12% at 256. Replaced with the curve plus why it does not matter (the solver is only ever called at 255 and 65535 levels, past the fine end of the table). - `nextPowerOf2` -> `turboPaddedLength`. The guard is right; the name promised a general-purpose helper while the function rejects anything over 2^20 with a message naming this module. - `turboQuantizeToTypedArray`'s third parameter drops its default and its `number | Options` union. `{ seed: 42 }` alone was a compile error, since the interface uses `T | undefined` rather than `T?`. Breaking change to a function that has not shipped. - `DEFAULT_SEED` is exported, so the task's schema default and destructuring default cite it instead of repeating the literal twice. - New required output `originalDimensions`, so a consumer can tell a widened vector from a model that genuinely emits that many dimensions. Tests: the accuracy test now scores against a max-abs reference computed in the test file, so repairing the quantizer cannot move its bounds. A second case pins the outlier-dimension claim; together they mean the corrected wording cannot silently rot in either direction. A third records the shipped linear path's max emitted code of 8 as a recorded defect, so its repair is a deliberate, reviewed change. Claude-Session: https://claude.ai/code/session_01UW1Qr5mxetAQr61YKEY9nz Co-authored-by: Claude <noreply@anthropic.com>
5e8cb99 to
7c525f0
Compare
`normalizeToUnit` fixed the sumSquares accumulator overflow, but the norm is
reconstructed as `maxAbs * rootS` and that product still overflows to Infinity
for a large-magnitude FINITE input. `turboQuantize` returned it unvalidated,
because `assertQuantizeResultShape` runs only on read paths:
turboQuantize(new Float64Array(4).fill(1e308), { bits: 8, seed: 42 })
yielded `norm: Infinity`, and `turboDequantize`, `turboQuantizedCosineSimilarity`
and `turboQuantizedInnerProduct` then all threw on it. The encoder was writing a
record nothing could read, and the failure surfaced three calls later phrased as
if the READER were malformed.
`turboQuantize` now rejects a non-finite norm with an encode-side message: it
names the largest absolute coordinate, states that the true L2 norm is not
representable as a double, and gives the two ways out — prescale the input
(cosine similarity is scale-free, so prescaling changes no similarity), or use
`turboQuantizeToTypedArray`, which discards the norm entirely and handles this
input unchanged.
The check is deliberately NOT in `normalizeToUnit`: `turboQuantizeToTypedArray`
calls it and destructures `values` only, so a 1e308 input is fine on that path
today and must stay fine. `normalizeToUnit` now also returns `maxAbs` so the
caller can report the magnitude that produced the overflow rather than only the
Infinity it became.
Also asserts `assertQuantizeResultShape` on the constructed record before
returning — the structural half of the same guarantee, that every record this
module hands out passes the check every reader applies. That holds today only by
coincidence, and the check is O(1) on already-computed scalars.
REJECTED: rescaling. There is nothing to rescale TO — the overflow means the
true L2 norm exceeds Number.MAX_VALUE and no double holds it. Representing it
would need a log-norm or (mantissa, exponent) field plus a
TURBO_QUANTIZE_VERSION bump, to serve inputs whose coordinates sit near 1e308.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lgxtp7mQECdh7F2UT9CVwN
The module doc claims "RANKING was never affected — ordering a candidate set was correct before and is correct now". That conflates two claims, one true by construction and one that had to be measured. These cases separate them, and land before the doc correction so the recorded numbers are measured rather than asserted. Test A pins the half that is structural: `invertShrinkage` is monotone at every corrected width, so switching the correction on or off moves every score and moves none past another. A PIN, not a red test — it fails only against a `shrinkageTable` whose quadrature is coarsened enough to make `g` non-monotone, the one mechanism by which the corrected wording could rot. 1 bit is excluded: its map is the closed form `cos(pi*(1 - r)/2)`, monotone by inspection, and covered end-to-end by Test B. The map saturates to exactly ±1 past the tabulated `g(1)` (2 bits from |r| >= 0.99, 3 bits from 0.995), a documented clamp — so the assertion is "never decreasing, and strictly increasing wherever the value is not pinned to an endpoint". A flat region ties scores; it does not invert them, which is the distinction the doc claim rests on. Test B measures the half that is not structural: how the estimator's ordering compares with the exact cosine's, per bit width. Deterministic corpus at d = 1024 (a power of two, so nothing is padded and the case measures the estimator rather than the padding): 480 background documents, 10 planted at t = 0.70..0.88 and 10 near-misses at t = 0.30..0.48. The 0.22 gap between the 10th and 11th planted document makes recall@10 insensitive to noise at every width; the 0.02 spacing inside the top 10 makes ordering sensitive. The reference ranking is `cosineSimilarity` on the unquantized vectors, never the planted `t`. Measured: | bits | recall@10 | top-10 order identical | Kendall tau @ top 20 | | ---- | --------- | ---------------------- | -------------------- | | 1 | 1.00 | no | 0.9368 | | 2 | 1.00 | yes | 0.9789 | | 4 | 1.00 | yes | 1.0000 | | 8 | 1.00 | yes | 1.0000 | recall and order are pinned two-sided, which is what pins the corrected wording in BOTH directions: "1 bit reorders the top 10" must fail if 1 bit ever stops reordering it. Tau is a floor, non-decreasing in bits, so the case does not read as flaky-shaped if the grid is retuned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lgxtp7mQECdh7F2UT9CVwN
…t shows The module doc said ranking "was correct before and is correct now" and listed "Preserves the RANKING induced by inner products and cosine similarity" as a property. The first half of that is true and the second is not, and they were being stated as one claim. What is true: the shrinkage correction is strictly monotone, so it moves every score and moves none past another — switching it on or off cannot change a ranking. That half is kept, and now says explicitly that it is a statement about the CORRECTION rather than about the estimator. What is not: whether the estimator's ordering matches the exact cosine's is a property of the quantizer, and at the low widths it is not perfect. The Properties bullet now carries the per-width numbers measured by the preceding commit, on the same corpus it cites: | bits | recall@10 | top-10 order identical | Kendall tau over the top 20 | | ---- | --------- | ---------------------- | --------------------------- | | 1 | 1.00 | no | 0.9368 | | 2 | 1.00 | yes | 0.9789 | | 4 | 1.00 | yes | 1.0000 | | 8 | 1.00 | yes | 1.0000 | Retrieval of a shortlist is unaffected at every width; the order within it is not. A recorded number per width, no prose adjective, matching the convention the rest of this file already follows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lgxtp7mQECdh7F2UT9CVwN
…cted `turboDequantize` never said that comparing its output with a plain `cosineSimilarity` yields the UNCORRECTED estimator at 1-4 bits. The warning lived only on `turboPrepareQuery`, which a caller reaches after they already understood the problem — while `turboDequantize` is the function on the natural integration path, since a Float32Array is the only output shape an IVectorStorage backend accepts. Adds a section stating it: decode-then-compare reads a true 0.80 as 0.59 at 1 bit and low-but-less-so at 2-4; the corrected number comes from `turboQuantizedCosineSimilarity` or `turboPrepareQuery` + `turboPreparedCosineSimilarity`; at 5-8 bits the two routes are interchangeable, and that boundary is `TURBO_MAX_CORRECTED_BITS`. Cross-links the test that pins it. Promotes the private `MAX_CORRECTED_BITS` to an exported `TURBO_MAX_CORRECTED_BITS` so a caller can branch on the boundary programmatically rather than reading a docstring and hoping. The internal name stays as an alias, so there is one literal. No "corrected-compare" helper, and the docstring says why: the correction inverts a map defined on the CODE-DOMAIN ratio `dot / (codeNormA * codeNormB)`, and a decoded Float32Array has already been renormalized to the recorded `norm`, destroying that ratio — so any helper taking two TurboQuantizeResults and returning a corrected number IS `turboQuantizedCosineSimilarity`. The real trap (an IVectorStorage backend scoring decoded vectors server-side with no hook) is not fixable in this module and is tracked as #798, which the module doc already cites. The docstring says that rather than implying a workaround exists. Test: exporting a constant creates a new way for docs and behavior to diverge, so a case sweeps bits 1-8 on one correlated pair and requires the corrected-vs-decoded gap to exceed 0.002 IFF `bits <= TURBO_MAX_CORRECTED_BITS`. It fails if the constant is retuned without the correction being extended, and equally in the other direction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lgxtp7mQECdh7F2UT9CVwN
The dimensionality guard asks a PER-VECTOR question — is this length a power of two? — about a BATCH-level invariant, so `[Float32Array(512), Float32Array(1024)]` passed it unchanged. Each vector was then rotated in its own `paddedLen` under its own sign table (`getSignTable(seed, paddedLen)` is a different basis per length), while `originalDimensions` reported only `vectors[0].length`. The same holds with `turboPadToPowerOf2: true` and lengths 700/1100, which land in 1024 and 2048. Turbo output is comparable ONLY against output with the same seed and, implicitly, the same padded length — so such a batch is not a batch, it is two incomparable encodings returned as one array with metadata describing the first. `executePreview` now rejects it. Turbo-only in this commit. An empty array throws naming the input, where it previously reached `getVectorType(undefined)` and threw "Unknown vector type: undefined". Otherwise the first vector whose length differs is reported with its index, both lengths, both padded lengths, the fact that the two live in different bases and are not comparable even after padding, and the remedy: quantize each dimensionality in a separate call. Tests (packages/test/src/test/rag/): the 512/1024 case, the 700/1100 case with padding enabled — which proves the guard is not the existing power-of-2 check under another name, since both inputs pass that check once padding is on — the empty-array case, and an explicit re-assertion that a homogeneous turbo batch still resolves. All three rejection cases fail against the pre-fix task. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lgxtp7mQECdh7F2UT9CVwN
Moves the guard before the `method` branch so it covers `linear` too, with a method-aware consequence clause, and folds in the mixed ELEMENT-TYPE case. Under linear the outputs keep their input lengths, so the break surfaces downstream — `cosineSimilarity` throws on any pair of them — but `originalDimensions` has already misreported before that, and a fixed-width storage column accepts one output and rejects the other. Mixed element types are the same defect on the other metadata field: `originalType` describes `vectors[0]` alone, and that is the field a consumer needs to reverse the quantization. Rejected for consistency with the dimensionality guard rather than left to misreport. THIS IS THE ONLY COMMIT IN THE BRANCH TOUCHING RELEASED BEHAVIOR. `VectorQuantizeTask`'s array input predates #354, unlike the codec and the turbo branch, so it is isolated here: a reviewer who objects drops exactly this commit and the rest of the branch stays coherent. The break is justified because it is from "silently wrong" to "loud". A caller passing a heterogeneous batch today already receives a result whose `originalDimensions` and `originalType` describe only `vectors[0]`, and whose outputs cannot share a storage column or be compared with each other — there is no correct use of that input shape. And there is no gradual path on offer anyway: the input schema is `additionalProperties: false`, so an opt-out flag cannot be added without the same kind of change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lgxtp7mQECdh7F2UT9CVwN
…d74u5n-turboquant-fixes fix(util,ai): TurboQuant norm overflow, ranking claim, decode warning, heterogeneous batches
…ndidate `turboPreparedCosineSimilarity` re-checked the three compatibility scalars (bits, seed, dimensions) but nothing about the query's coordinates, while the dot product is bounded by the CANDIDATE's padded length. A `values` shorter than that read past its own end: every such read is `undefined -> NaN`, NaN survives `finishCosine` to become the score, and a ranking sort over NaN is implementation-defined rather than an error — so the failure surfaced as an arbitrary shortlist with nothing reported. Three guards now reject it, placed before the zero early-return so a malformed zero query cannot slip past. Also make the shrinkage map exact at rho = 1. `shrinkageAt` cannot evaluate that point — the `conditionalSd` floor turns the inner conditional expectation into a step function landing on a bin boundary, so Simpson loses ~1.7% of the cross term at 1 bit while `square` stays exact — and the tabulated last knot came out at 0.983377 / 0.989762 / 0.994138 / 0.996838 at 1/2/3/4 bits against a comment claiming g(1) = 1. Every ratio above that knot therefore collapsed to exactly 1. Returning 1 analytically at rho = 1 (the pair is degenerate there: y = x, so E[Q(x)Q(y)] IS E[Q(x)^2]) takes the worst disagreement with the 1-bit Goemans-Williamson closed form from 2.776e-4 to 1.158e-4 on the test's own grid, with the table still strictly increasing at all four widths. No TURBO_QUANTIZE_VERSION bump: the shrinkage map is a scoring-time computation, and the version covers only the codes -> reconstructed-values mapping. Blast radius is bounded — `finishCosine` uses the closed form at 1 bit so no 1-bit score moves, and at 2-4 bits only ratios above the previous last knot move, i.e. true cosines above ~0.9995. Self-similarity still returns exactly 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJRf3YFa8DjmjsZvXz8xDT
`TurboQuantize.ts`'s module doc claims the typed-array encoder "drops into any backend declared at the padded width", and nothing ran it. Three things were asserted only in prose: that `turboQuantizeToTypedArray`'s output is a shape `assertVectorShape` accepts entry by entry (the packed codec deliberately has no storage path, so this is the only turbo encoder that does), that the length is `turboPaddedLength(d)` and not `d` end to end, and that a padded vector still retrieves its neighbour. A regression silently cropping or re-padding, or an `assertVectorShape` change rejecting `Int8Array`, was invisible to every other test in this area. The round trip goes through `putBulk` (which is what runs `validateVectorEntities`) into an `InMemoryVectorStorage` declared at 1024 for a 768-dimensional embedding, then back out of `similaritySearch`. The companion case executes the footgun four separate error messages in this codebase warn about: a store declared at `d` rejects the widened vector on write, which is why `turboPadToPowerOf2` defaults to false. Added to the existing rag test file rather than a new one — it already owns the task's turbo fixtures and timing hooks. The corpus is kept to ~25 vectors because each one is a full workflow run, and it reuses `turboOf` rather than building a second quantization path: the point is that what the TASK emits is storable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJRf3YFa8DjmjsZvXz8xDT
…ublic disclaimer Three documentation defects, each of which sends a reader the wrong way. `turboQuantizedInnerProduct` said "for maximum accuracy, dequantize both sides and take a real dot product". That is the UNCORRECTED estimator below TURBO_MAX_CORRECTED_BITS and reads systematically LOW with nothing reported: measured at d=1024 over 25 Gaussian pairs at true cosine 0.80, against a true mean inner product of 816.8, this helper returns 823.8 while dequantize-then- dot returns 611.2 at 1 bit (-25%), 739.7 at 2 bits (-9%) and 808.7 at 4 bits (-1%). At 5-8 bits the two agree exactly, which is the boundary the constant names. The docstring now says so and points a candidate-set caller at `turboPrepareQuery` + `turboPreparedCosineSimilarity`. The module doc's bias table is captioned "BEFORE the correction and as shipped", but the 1-bit row's "before" column is already Goemans-Williamson- corrected — that correction landed in an earlier commit than the 2-4 bit one. Read as-is the table says 1 bit has no shrinkage to correct, while the code says the raw statistic reads a true 0.80 as 0.59. The caption now names the 2-4 bit correction, the row is marked, and a note gives the genuinely uncorrected 1-bit bias from the closed form: -0.167 at a true 0.50, -0.210 at 0.80, -0.152 at 0.95, roughly 200x the largest number in the table. `VectorQuantizeTask`'s `method` schema description is the one UI-visible surface, and it repeated the "TurboQuant" name with no disclaimer. It now carries the same one the module doc does: the name refers to the borrowed rotation strategy, not to the paper's distribution-fitted level placement, which is not implemented. Renaming the symbols is deliberately NOT done here and is filed as a follow-up. `QuantizationMethod.TURBO`'s "turbo" is a persisted enum value in serialized workflow JSON and in `VectorQuantizeTaskOutput.method`, and `turboSeed` / `turboPadToPowerOf2` are named input ports — so a rename breaks dataflow edges in saved graphs and invalidates cached task outputs. Deprecated aliases cover function names but not port names or the persisted enum without a migration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJRf3YFa8DjmjsZvXz8xDT
… code array `optimalLoadingFactor` solved the two level counts the signed typed-array path reaches (255 for int8, 65535 for int16 — never powers of two, so absent from the table) by ternary search over `quantizerDistortion`, which calls `Math.exp` ~1200 times per evaluation. ECMAScript leaves `Math.exp` implementation- approximated, and this repo runs the same tests under JSC (`bun test`) and V8 (vitest under Node). Measured here the two disagree at 255 levels: 3.9206374677710735810 on Node v22.22.2 against 3.9206374677728756950 on Bun 1.3.11, a relative 4.6e-13. 65535 happens to agree. That is small enough to produce the same bytes today and not small enough to guarantee it — a code is `Math.round((clamped / scale) * max)`, and a value near a half-integer can flip either way — so the golden-bytes test, which asserts an exact sequence derived from this constant, was a weaker pin than it read. Both values are now literals in `SOLVED_LOADING_FACTORS`, consulted via `Object.hasOwn` (a bare index resolves inherited `Object.prototype` keys). The ternary search stays as the fallback for any other level count. Tabulating the exact doubles is preferred to rounding to N significant digits, which would perturb `scale` and could silently change the checked-in bytes. The golden-bytes test gains an INT16 block over the same fixture: the existing int8 block pins the 255 literal, and nothing pinned 65535 at all. Both blocks were run under vitest AND `bun test` and produce identical bytes. Also drop the encoder's boxed `number[]` for a `Uint8Array` — safe by construction, since `assertBits` caps `bits` at 8 so every code is in [0, 255], and `packCodes` reads only `.length` and `codes[i]` (its signature widens to `ArrayLike<number>`). `unpackCodes` already justifies this choice for the read path. Measured peak RSS growth around a single `turboQuantize` at d = 2^20 falls from ~31 MB to ~25 MB, so `MAX_TURBO_DIMENSIONS`'s docstring figure is re-measured rather than left stale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJRf3YFa8DjmjsZvXz8xDT
…xes-354 Review fixes for PR #354 (TurboQuant): NaN-scoring prepared query, exact shrinkage endpoint, engine-pinned loading factors, corrected docs
TurboQuantizeOptions- makebitsandseedoptional fieldscreatePrngseed=0 handling by XOR-mixing seed with golden-ratio constantpaddedDimensionsthroughout (avoids dropping coordinates for non-power-of-2 dims)getQuantizationParams(accurately describes uniform quantizer now)unpackCodes()VectorQuantizeTaskto reporttargetType: FLOAT32(matches actual Float32Array output)turboBits/turboSeedschema fields fromtype: "number"totype: "integer"VectorQuantizeTask.test.ts(type/metadata, determinism, array-of-vectors)TurboQuantize.test.tsstorage/compression tests for padded-dimension calculations