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
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
e677fa8 to
21c1db6
Compare
…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.
…hxoj5s-turboquant fix(util): correct TurboQuant's 1-bit cosine estimator and document the shrinkage
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