fix(util,ai): restrict TurboQuant to signed targets; harden input validation - #713
Merged
sroussey merged 1 commit intoAug 7, 2026
Conversation
…idation
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 Opus 5 <noreply@anthropic.com>
Coverage Report
File CoverageNo changed files found. |
sroussey
added a commit
that referenced
this pull request
Aug 11, 2026
…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>
sroussey
added a commit
that referenced
this pull request
Aug 13, 2026
…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>
sroussey
added a commit
that referenced
this pull request
Aug 16, 2026
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Review fixes stacking on #354 (
claude/integrate-arxiv-paper-VF55c) — not onmain. No PR template exists in this repo (.github/, root,docs/), so this uses plain headings.CRITICAL: unsigned turbo quantization encodes a DC offset that breaks cosine similarity
turboQuantizeToTypedArray's unsigned branch mappedan affine map. Its 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 every comparison.
Measured on this branch (d=1024, uint8, seed 42, mulberry32 vectors), replicating the removed branch exactly:
The plan's independent measurement on a different random stream reported the same failure shape (true 0.0559 → 0.9071, range collapsing to ~[0.90, 1.00]). Negative similarities become impossible and absolute thresholds are meaningless — silently, with no error anywhere.
Decision: reject unsigned targets outright.
uint8/uint16now throw; onlyint8andint16are supported.The alternatives do not work:
cosineSimilarity(a, b)(packages/util/src/vector/VectorSimilarityUtils.ts) receives only the two arrays. There is nowhere to thread an offset.Nothing has landed on
main, so there is no migration burden, and unsigned buys zero storage over the signed type of the same width.Each fix
INTEGER_TARGET_RANGES→SIGNED_TARGET_RANGES(int8/int16 only). Guard is nowObject.hasOwn(...)before indexing —INTEGER_TARGET_RANGES["constructor"]previously resolved an inheritedObject.prototypekey, soif (!range)saw a truthy function andturboQuantizeToTypedArray(v, "constructor")returned an all-zeroUint16Arrayinstead of throwing. Unsigned branch andsignedflag deleted; JSDoc rewritten (signed-only, comparability caveats, the removed "works transparently with … similarity search" claim qualified).TurboQuantize.tsnormalizeToUnit(), shared byturboQuantizeandturboQuantizeToTypedArray, which throws on a non-finite norm. Previouslynew Float32Array([1,2,NaN,4,5,6,7,8])madenormNaN, failednorm > 0, and returned the freshly-allocated all-zero buffer — the NaN silently discarded. Zero-vector behaviour is preserved.TurboQuantize.tsnextPowerOf2usedp <<= 1, a 32-bit signed shift: atp = 2^30it wraps to-2147483648, then0, andp < nstays true forever. Changed top *= 2, plusMAX_TURBO_DIMENSIONS = 2**24(the paddedFloat64Arrayworking buffer is 128 MB there) andassertDimensions/assertBits, now called from both exported helpers.turboQuantizeStorageBytes(1.5e9, 4)hung the event loop permanently;bitsof0,-5,NaNsilently returned1.TurboQuantize.tsfastWalshHadamardassumed a power-of-2 length; at n=6 it readdata[j + halfSize]past the end (undefined→ NaN), so aTurboQuantizeResultwithpaddedDimensions: 6decoded to[NaN x 6]with no error. Since that interface is plain and serializable (intended for storage), a persisted or mismatched record corrupted silently. The transform now enforces the invariant, andturboDequantize/turboQuantizedInnerProductre-validatebits,dimensions,seed,normandpaddedDimensions === nextPowerOf2(dimensions)before use.TurboQuantize.tsrandomRotatereturnspaddedLencoordinates (correctly — that is what makes it invertible) but only the firstdare kept, so for a non-power-of-2dthis is a random projection, not an orthogonal rotation. Fixed-length output makes that unavoidable, and 768/1536 are the two most common embedding dims among this repo's providers, so throwing is not acceptable. Measured cosine RMSE (int8, seed 42, 40 pairs) is now in the JSDoc: d=1024 → 0.001, d=1000 → 0.006, d=1536 → 0.013, d=768 → 0.019 (worst single pair 0.052), with a recommendation to zero-pad at the call site. Mirrored into themethodschema description.TurboQuantize.ts,VectorQuantizeTask.tsgetSignTable(seed, paddedLen)returning threeUint8Arrayflip masks drawn in exactly the previous order, memoized in a boundedMap(16 entries, oldest evicted) so a hostile seed stream cannot grow it.inverseRandomRotatepreviously rebuilt a boxedboolean[][]of 3 × paddedLen on every dequantize.unpackCodesnow returns aUint8Array(bits ≤ 8) instead of a boxednumber[]—turboQuantizedInnerProduct, documented as "faster than dequantizing", was allocating two 1024-element JS arrays per comparison. Output bytes are unchanged (pinned by the new golden test).TurboQuantize.tsVectorQuantizeTaskOutputcarried onlyvector/originalType/targetType, so nothing downstream could tell a rotatedInt8Arrayfrom a linear-quantized one — measured,cosineSimilarity(a, turboQuantizeToTypedArray(a, INT8, 42))≈ 0.05, a vector near-orthogonal to itself. A collection re-indexed withturboSeed42→43, or a row written withmethod: "linear", produced garbage rankings with no error.methodandturboSeedare now inoutputSchema(withmethodrequired) and on the output type. The turbo branch rejects non-signedtargetTypeearly, and thenormalizedescription notes it is ignored for turbo.VectorQuantizeTask.ts2026on the two files created 2026-04-01 (TurboQuantize.ts,TurboQuantize.test.ts); the pre-existing files stay at 2025 per CLAUDE.md.TurboQuantizeOptionsfields →readonly bits: number | undefined/readonly seed: number | undefined.createPrng:state ^= state >> 17→>>> 17(cosmetic — the JSDoc calls it xorshift32 — but it changes output bytes, so it was done before the golden literals were generated).TurboQuantize.ts, testsTests
10 new tests plus an extended zero-vector case. All fidelity/statistical tests use a local deterministic mulberry32 PRNG — never
Math.random.TurboQuantize.test.ts: unsigned rejection; DC-offset guard (d=1024 int8 — asserts|mean| < 2and ≥30% negative coordinates, both of which an affine map fails); NaN/Infinity on both entry points; out-of-range dimensions and bits (incl.2**30 + 1under a 2 s timeout so a hang fails rather than stalls CI); tamperedpaddedDimensionsonturboDequantizeandturboQuantizedInnerProduct; prototype-chain target names; cosine fidelity across d ∈ {768, 1000, 1024, 1536}; and hardcoded golden bytes ([117, 165, 180, 133]and a 16-value int8 prefix) — the existing determinism test only compared two in-process calls, which cannot detect aMath.randomregression introduced elsewhere.VectorQuantizeTask.test.ts: unsignedtargetTyperejection for turbo;method/turboSeedreported on the output.What was verified vs. not
Verified — commands run, output seen:
bun install→ 1830 packages installed.bun run build:types→ 41/41 tasks successful (full workspace,tsgo).bunx vitest run packages/test/src/test/util/TurboQuantize.test.ts packages/test/src/test/rag/VectorQuantizeTask.test.ts→ 67 passed / 67.git show HEAD:…) and the new tests in place: 9 of the new tests failed — NaN/Infinity, golden bytes, tamperedpaddedDimensions, float-target message, unsigned rejection, prototype-chain names, zero-vector (uint8 arm), and bothVectorQuantizeTasktests.turboQuantizeStorageBytes(2**30 + 1, 4)spins synchronously, so it blocks the event loop and vitest's own timeout never fires — a first attempt burned a full 10-minute shell timeout. Isolated withtimeout 15 bun -e …it exits 124 (hang) on the old source and throws immediately on the new one. That is the DoS, demonstrated.prettier --writeandeslintclean on all four changed files (pre-commitlint-stagedalso ran).Not verified / caveats:
INT8, whose signed map was already correct — they are forward regression guards (against reintroducing an affine map, and against the documented RMSE drifting), not reproductions of a current failure. Flagging this because the plan asked that every new test fail before.bun run use-source(this repo'suse-sourcerewritespackage.jsonexports rather than writingdiststubs as CLAUDE.md describes). Thosepackage.jsonedits were reverted before committing — the commit touches exactly 4 files. Underdistresolution the suite cannot run without a prior build.build:typesacross all 41 packages is the only whole-workspace signal here.Deviation from the approved plan
One, stated explicitly. The plan asked to drop
?frommethod/turboSeedonVectorQuantizeTaskInput(making themreadonly method: QuantizationMethod | undefined). I addedreadonlybut kept the?, because the task'sinputSchemalists neither inrequired— they genuinely are optional inputs — and removing?makes every existingvectorQuantize({ vector, targetType })call site a type error, including the pre-existing tests inVectorQuantizeTask.test.ts. It would also have been inconsistent with the pre-existingnormalize?, which the plan explicitly left alone.TurboQuantizeOptionsdid get the full treatment (readonly bits: number | undefined) as specified — it has no external callers, so the change is contained. On the output type,methodis required andturboSeedisnumber | undefined(set toundefinedfor linear), exactly as planned.🤖 Generated with Claude Code
Generated by Claude Code