Skip to content

Add TurboQuant vector quantization algorithm - #354

Draft
sroussey wants to merge 13 commits into
mainfrom
claude/integrate-arxiv-paper-VF55c
Draft

Add TurboQuant vector quantization algorithm#354
sroussey wants to merge 13 commits into
mainfrom
claude/integrate-arxiv-paper-VF55c

Conversation

@sroussey

@sroussey sroussey commented Apr 1, 2026

Copy link
Copy Markdown
Collaborator
  • Fix TurboQuantizeOptions - make bits and seed optional fields
  • Fix createPrng seed=0 handling by XOR-mixing seed with golden-ratio constant
  • Fix rotation correctness: store and use paddedDimensions throughout (avoids dropping coordinates for non-power-of-2 dims)
  • Fix misleading docstring in getQuantizationParams (accurately describes uniform quantizer now)
  • Add buffer length validation to unpackCodes()
  • Fix TURBO branch in VectorQuantizeTask to report targetType: FLOAT32 (matches actual Float32Array output)
  • Change turboBits/turboSeed schema fields from type: "number" to type: "integer"
  • Add turbo-method test cases to VectorQuantizeTask.test.ts (type/metadata, determinism, array-of-vectors)
  • Update TurboQuantize.test.ts storage/compression tests for padded-dimension calculations

Comment thread packages/util/src/vector/TurboQuantize.ts Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.ts implementing TurboQuant quantize/dequantize + similarity helpers and storage sizing utilities.
  • Exported TurboQuant APIs from packages/util/src/schema-entry.ts.
  • Added VectorQuantizeTask input options for selecting linear vs turbo behavior.
  • Added TurboQuantize unit 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.

Comment on lines +32 to +34
readonly bits: number;
/** Seed for deterministic random rotation. If omitted, uses a fixed default seed. */
readonly seed: number | undefined;

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
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;

Copilot uses AI. Check for mistakes.
Comment on lines +59 to +66
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;

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
Comment on lines +80 to +104
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);
}

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +248 to +266
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;

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +165 to +174
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));
}

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +152 to +174
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));
}

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
Comment on lines +174 to +192
/**
* 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 };

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +83
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,
},

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

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 uses AI. Check for mistakes.
@sroussey

sroussey commented Apr 1, 2026

Copy link
Copy Markdown
Collaborator Author

@copilot apply changes based on the comments in this thread

Copilot AI commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

@copilot apply changes based on the comments in this thread

Applied all 8 review changes in commit 051f0b7:

  1. TurboQuantizeOptionsbits and seed are now optional (?), so callers can pass {} or { bits: 4 }.
  2. createPrng seed=0 — seed is now XOR-mixed with 0x9e3779b9 before use, giving every distinct integer seed (including 0) its own distinct non-zero PRNG state.
  3. Rotation orthogonalityrandomRotate now returns all paddedLen coordinates instead of truncating to d; TurboQuantizeResult gains paddedDimensions; turboDequantize and turboQuantizedInnerProduct use paddedDimensions throughout, preserving full invertibility for non-power-of-2 dimensions like 768.
  4. getQuantizationParams docstring — rewritten to accurately describe the fixed-range uniform quantizer (no more "Beta distribution / optimal boundaries" language).
  5. unpackCodes validation — throws with a descriptive message when the buffer is smaller than ceil(count * bits / 8).
  6. turboBits/turboSeed schema — changed from type: "number" to type: "integer".
  7. TURBO branch return type — now explicitly returns targetType: TensorType.FLOAT32 to match the actual Float32Array output.
  8. New VectorQuantizeTask turbo tests — three cases: correct type/metadata, determinism with fixed seed, and array-of-vectors. Storage/compression ratio tests in TurboQuantize.test.ts updated for the padded-dimension calculations.

@sroussey
sroussey force-pushed the main branch 3 times, most recently from 9e9875f to 48ad905 Compare May 8, 2026 02:25
@sroussey
sroussey force-pushed the claude/integrate-arxiv-paper-VF55c branch from 447c7b0 to d7ec70d Compare August 6, 2026 18:05
@sroussey

sroussey commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

@claude rebase on main

@sroussey
sroussey force-pushed the claude/integrate-arxiv-paper-VF55c branch from d7ec70d to d031344 Compare August 7, 2026 01:44
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 59.98% 37695 / 62840
🔵 Statements 59.5% 39564 / 66484
🔵 Functions 60.81% 7280 / 11971
🔵 Branches 48.2% 19160 / 39746
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/ai/src/task/VectorQuantizeTask.ts 15.66% 0% 10.52% 19.11% 187-349, 358
Generated in workflow #3096 for commit 00f0b44 by the Vitest Coverage Report Action

@sroussey
sroussey marked this pull request as draft August 7, 2026 02:01
sroussey added a commit that referenced this pull request Aug 8, 2026
…-fixes-p4d8qo

fix(util,ai): correct TurboQuant quantization grid and harden its decode path (follow-up to #354)
sroussey pushed a commit that referenced this pull request Aug 11, 2026
…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
@sroussey
sroussey force-pushed the claude/integrate-arxiv-paper-VF55c branch from 0278919 to e677fa8 Compare August 11, 2026 03:08
sroussey and others added 7 commits August 13, 2026 03:16
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
@sroussey
sroussey force-pushed the claude/integrate-arxiv-paper-VF55c branch from e677fa8 to 21c1db6 Compare August 13, 2026 03:16
sroussey and others added 6 commits August 13, 2026 08:18
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants