Skip to content

Add TurboQuant vector quantization algorithm - #354

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

Add TurboQuant vector quantization algorithm#354
sroussey wants to merge 26 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 61.07% 40030 / 65546
🔵 Statements 60.57% 42046 / 69409
🔵 Functions 61.6% 7723 / 12536
🔵 Branches 49.47% 20591 / 41618
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/ai/src/task/VectorQuantizeTask.ts 13.26% 0% 9.09% 16.04% 201-441, 450
Generated in workflow #3205 for commit f4d3a6f 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 pushed a commit that referenced this pull request Aug 13, 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 e677fa8 to 21c1db6 Compare August 13, 2026 03:16
sroussey and others added 14 commits August 16, 2026 16:28
Implement near-optimal vector quantization based on "TurboQuant: Online
Vector Quantization with Near-optimal Distortion Rate" (Zandieh et al.,
2025). The algorithm uses randomized Walsh-Hadamard rotation + optimal
per-coordinate scalar quantization to achieve ~2.7x of theoretical
distortion limits. Data-oblivious and per-vector, making it ideal for
streaming RAG pipelines.

- Add turboQuantize/turboDequantize in @workglow/util/schema
- Add turboQuantizedInnerProduct/turboQuantizedCosineSimilarity for
  direct similarity on quantized vectors
- Extend VectorQuantizeTask with "turbo" method option and turboBits/
  turboSeed parameters
- Add 29 tests covering roundtrip quality, compression, and similarity

https://claude.ai/code/session_01YD75mdbcw6ygET7hdjQdWD
…tible output

TurboQuant's rotation + optimal scalar quantization now outputs directly
into byte-aligned TypedArrays (Int8Array, Uint8Array, Int16Array,
Uint16Array) with the same .length as the input vector. This means the
output works transparently with all existing storage backends and
cosineSimilarity search — no dimensional mismatch.

- Add turboQuantizeToTypedArray() that rotates then quantizes into the
  target integer type at its native bit width
- Update VectorQuantizeTask turbo branch to call turboQuantizeToTypedArray
  directly instead of quantize+dequantize roundtrip
- Remove turboBits parameter (bit width determined by targetType)
- Add 14 tests for the new function covering type output, similarity
  preservation, determinism, range bounds, and edge cases

https://claude.ai/code/session_01YD75mdbcw6ygET7hdjQdWD
These files predate #683's eslint/prettier config changes and had never
been run through the formatter.
…idation (#713)

Review fixes stacking on the TurboQuant integration branch.

CRITICAL: turboQuantizeToTypedArray's unsigned branch mapped
x -> (x + scale) / (2 * scale) * max, an affine map whose DC offset (127.5
for uint8) lands on every stored coordinate. Cosine similarity is invariant
to scaling but not to translation, so that shared component dominates: at
d=1024 / uint8 / seed 42 a true cosine of 0.0139 reads 0.9018, and across 40
random pairs the whole range collapses to [0.893, 0.907] — negatives become
impossible and absolute thresholds meaningless. The offset cannot be threaded
back out (cosineSimilarity takes only the two arrays, and pgvector / SQLite /
DuckDB compute distance server-side), and unsigned buys no storage over the
signed type of the same width. Nothing has shipped on main, so uint8/uint16
are now rejected outright rather than silently corrupting rankings.

Also fixed:
- prototype-chain target names ("constructor", "__proto__") resolved an
  inherited Object.prototype value and slipped past the `if (!range)` guard,
  returning an all-zero Uint16Array; now guarded with Object.hasOwn before
  indexing.
- NaN / Infinity input produced a non-finite norm, failed `norm > 0`, and
  returned the freshly allocated all-zero buffer with no error. Extracted
  normalizeToUnit() and made it throw.
- nextPowerOf2 used `p <<= 1`, a 32-bit signed shift that wraps to 0 at
  2^30 and loops forever; turboQuantizeStorageBytes(2**30 + 1, 4) hung the
  event loop permanently. Now `p *= 2`, with assertDimensions (integer,
  1..2^24) and assertBits (integer 1..8) validating both exported helpers.
- fastWalshHadamard assumed a power-of-2 length and read past the buffer
  otherwise, so a TurboQuantizeResult carrying paddedDimensions: 6 decoded to
  all-NaN silently. The transform now enforces the invariant, and
  turboDequantize / turboQuantizedInnerProduct re-validate bits, dimensions,
  seed, norm and paddedDimensions before use — that record is a plain
  serializable interface intended for storage.
- inverseRandomRotate rebuilt a 3 x paddedLen boxed boolean[][] on every
  dequantize; sign masks are now built once by getSignTable and memoized in a
  bounded (16-entry, oldest-evicted) cache. unpackCodes returns a Uint8Array
  instead of a boxed number[], so turboQuantizedInnerProduct no longer
  allocates two 1024-element JS arrays per comparison. Output is unchanged.
- VectorQuantizeTaskOutput now records `method` and `turboSeed`, so a
  consumer can tell a rotated Int8Array from a linear-quantized one. Nothing
  downstream could previously detect a collection re-indexed with a different
  seed or mixed with method: "linear"; both produce garbage rankings with no
  error. The turbo branch also rejects non-signed targetType early.
- Documented (not "fixed") the fixed-length-output projection: randomRotate
  produces nextPowerOf2(d) coordinates but only the first d are kept, so a
  non-power-of-2 d is a random projection, not an orthogonal rotation.
  Measured int8 cosine RMSE (seed 42, 40 pairs): d=1024 -> 0.001,
  d=1000 -> 0.006, d=1536 -> 0.013, d=768 -> 0.019.
- Conventions: license year 2026 on the two files created in 2026,
  readonly `T | undefined` on TurboQuantizeOptions, and `>>> 17` in the
  xorshift32 PRNG (which is why the golden byte literals are what they are).

Tests: unsigned/float/prototype-chain rejection, DC-offset (zero-centred
signed output), NaN/Infinity, out-of-range dimensions and bits, tampered
paddedDimensions, cross-dimension cosine fidelity, hardcoded golden bytes for
cross-process determinism, and output method/turboSeed reporting. All use a
local deterministic PRNG, never Math.random.

Co-authored-by: Claude <noreply@anthropic.com>
…ode path

Follow-up to #354, which introduced `TurboQuantize.ts`. The module is unreleased,
so the encoded format changes here affect no persisted data.

The quantization grid used a clipping range fixed at 3 standard deviations for
every bit width. That is only near-optimal around 4 bits, and wrong in both
directions elsewhere:

- At 1 bit the two reconstruction points sat at +/-3 sigma, so a reconstruction
  came back exactly 3.0x too long and `turboQuantizedCosineSimilarity(q, q)`
  returned 9.0 from a function documented to return [-1, 1].
- From 6 bits up, the bits-independent clipping error dominated everything the
  extra levels bought. Measured relative L2 at d=1024 was 0.0429 / 0.0356 /
  0.0336 at 6 / 7 / 8 bits: four times the levels for a 22% gain.

The clipping range is now the MSE-optimal loading factor for a unit-variance
Gaussian at each bit width, tabulated from Max (1960) and solved numerically for
the level counts the typed-array path uses (255 for int8, 65535 for int16), which
are never powers of two. Both call sites read the same helper rather than
repeating a literal. Reconstruction is additionally renormalized to the recorded
L2 norm, and the similarity helpers divide by each reconstruction's own norm, so
cosine is an actual cosine: self-similarity is exactly 1 and the documented range
holds by construction.

Measured at d=1024, seed 42 (relative L2 by bit width, before -> after):

  1 bit  2.2825 -> 0.6351      5 bits 0.0647 -> 0.0648
  2 bits 0.5881 -> 0.3474      6 bits 0.0429 -> 0.0382
  3 bits 0.2496 -> 0.1889      7 bits 0.0356 -> 0.0213
  4 bits 0.1229 -> 0.1099      8 bits 0.0336 -> 0.0098

Every step now improves by at least 15%, which is what the new monotonicity
assertion pins; per-bit ceilings alone would not have caught the flat tail.
Mean absolute inner-product error at d=1024 improves 0.0566 -> 0.0252.

`turboQuantizeToTypedArray` kept only the first `d` of `nextPowerOf2(d)` rotated
coordinates, making it a lossy random projection whenever `d` was not a power of
two -- measurably worse than the plain linear quantizer it was documented to beat
(int8 cosine RMSE vs linear: 0.0164 vs 0.0027 at d=768, 0.0126 vs 0.0033 at
d=1536, 0.0094 vs 0.0026 at d=3072; it wins only at d=1024). Those are MiniLM,
text-embedding-3-small and text-embedding-3-large. It now rejects a non-power-of-2
length, with `{ padToPowerOf2: true }` to opt into a longer result instead
(padding d=768 measures 0.00034 RMSE against 0.01639 for cropping).
`VectorQuantizeTask` surfaces the rejection with both remedies named, since from a
task caller's seat the underlying throw reads as a bug. `turboQuantize` is
unaffected: it keeps all padded coordinates and stays invertible at any size.

Decode-path hardening:

- `assertQuantizeResultShape` rejects a `codes` field that is not a `Uint8Array`
  before any other check. `TurboQuantizeResult` is a plain serializable record, so
  the obvious way to persist one is JSON -- which turns `codes` into an object
  with no usable `length`. Since `undefined < expectedBytes` is false, the
  existing size guard waved it through and every byte decoded as NaN -> code 0,
  producing a confident vector of garbage rather than an error.
- Seeds are validated on encode, not only on decode. A non-integer seed
  previously encoded successfully and then failed to decode, which is data
  written and permanently unreadable. Seeds outside the int32 window are also
  rejected: `2**32 + 1` silently aliased onto `1`.
- Records carry `version: 1`, checked on every decode, so the next grid change
  fails loudly instead of mis-scaling silently.

`MAX_TURBO_DIMENSIONS` drops from 2^24 to 2^20. Its justification cited a single
128 MB buffer, but peak RSS growth measures 32 MB at d=2^20 (~32 bytes per padded
coordinate, several working buffers), so the old cap allowed far more than
advertised. The sign-table cache is now bounded by bytes rather than entry count
-- 16 entries at the old maximum retained hundreds of megabytes -- and
`clearSignTableCache()` is exported, since the cache is process-global and had no
release path. Verified: 12 distinct 3 MB tables retain 6.00 MB against the 8 MB
budget, and eviction is transparent (a recomputed table reproduces codes exactly).

The header doc claimed "optimal per-coordinate scalar quantization", coordinates
concentrating around a Beta distribution, and "within ~2.7x of theoretical
distortion limit at all bit-widths", while `getQuantizationParams` conceded 280
lines away that no distribution-fitted quantization happens. It now states what
the module actually does and what it leaves unimplemented.

Golden-byte fixtures are re-pinned to the new grid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6huUY7hSkRbjun1P9HKsz
…ld not fail (#757)

* fix(util): scale the TurboQuant norm so extreme vectors survive quantization

`normalizeToUnit` accumulated `sumSquares += v * v` on raw coordinates, which
squares the input's exponent: the running sum left the double range long before
the vector did. Above ~1e154 it overflowed to Infinity, so a finite input was
rejected as "containing NaN or Infinity"; below ~1e-162 it underflowed to 0, so
a finite input was recorded as a zero vector, decoded to all zeroes and scored 0
against itself.

Replaced with a max-scaled two-pass norm. The per-element finiteness check moved
into pass 1 and is load-bearing under max-scaling: an Infinity input would
otherwise set maxAbs = Infinity and every scaled coordinate would become NaN.
The final division is by maxAbs and then by rootS, never by their product, which
is what keeps subnormal inputs alive.

Also hardened the decode path:

- `turboDequantize` rejects a norm above the Float32 maximum. The output is a
  Float32Array whose L2 norm IS `norm`, so an out-of-range one decoded to
  all-Infinity silently. The check is on the recorded scalar, so it is O(1) and
  confined to the decode — cosine similarity is scale-free and keeps working on
  such records.
- `assertQuantizeResultShape` rejects a negative norm. It is always a Math.sqrt
  result, and the decode multiplies by it, so a negative one sign-flipped the
  whole reconstruction with nothing reported.

TURBO_QUANTIZE_VERSION stays at 1: the code-to-value mapping is untouched.
[1,2,3,4] still records norm === Math.sqrt(30), codes [175,104,163,116] and a
bit-identical decode, and both golden-byte vectors are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomTUtZSTgUbFCYqFs4pcu

* test(util): make the TurboQuantize suite runnable and give three tests teeth

The suite imported `getTestingLogger` from `../../binding/TestingLogger`, which
does not exist, so the file failed to load and NONE of its 66 tests ran. Fixed
to `@workglow/util/test`, matching every other test in the package.

With the suite running, three of its tests could not fail:

- the two magnitude tests: `turboDequantize` unconditionally rescales by
  `norm / croppedNorm`, so the magnitude ratio is exactly 1 for every input, bit
  width and grid;
- the self-similarity test: `quantizedCosine` divides by each side's own
  `codeNorm`, so a record scores 1 against itself by algebra.

Verified by swapping the loading-factor table for a fixed 3-sigma array: all
three stayed green while the grid regression they appear to guard was live.

Replaced with measurements that move:

- magnitude folded into the existing relative-L2 test as a one-line invariant,
  plus a new cropped (d=768) relative-L2 test with per-bit ceilings and a
  `relativeL2[i+1] < relativeL2[i] * 0.85` step. The cropped path renormalizes
  against the first 768 of 1024 coordinates, so the padded test does not cover
  it. Ceilings measured on both grids; the 3-sigma row is BETTER at 6-8 bits
  there, so the step assertion alone passes it and the 2/3-bit ceilings are what
  reject it.
- self-similarity replaced by RMSE of (quantized cosine - exact cosine) over 24
  seeded pairs at d=1024, per bit width, against ceilings with >=21% headroom on
  the shipped grid that reject the 3-sigma one at 2, 3 and 8 bits. The `<= 1.0`
  self-similarity line survives as one line of the existing range test.

The "higher dimensions" test additionally used `Math.random()`. It is now seeded,
but NOT with the `sim256 > sim64` assertion its name implied: measured over 200
seeded draws at 4 bits, mean reconstruction cosine is 0.99496 at d=64 versus
0.99441 at d=256 — slightly worse, with d=256 winning only 63 of 200. What does
improve with dimension is pairwise similarity-estimation error (RMSE 0.0186 /
0.0101 / 0.0047 at d=64/256/1024), so that is what it now asserts and what it is
now named for.

Also pins the norm fix: overflow, underflow, the Float32 decode ceiling, the
negative-norm guard, and a bit-identical [1,2,3,4] round-trip.

Re-verified under the 3-sigma grid: 6 tests now fail where 3 did before, and all
three replacements are among them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomTUtZSTgUbFCYqFs4pcu

---------

Co-authored-by: Claude <noreply@anthropic.com>
…its guidance (#762)

`executePreview` hard-rejected every non-power-of-2 dimensionality for
`method: "turbo"` and never exposed `turboQuantizeToTypedArray`'s
`{ padToPowerOf2: true }`, so the accurate option was unreachable from the task.
Worse, the rejection message quoted RMSE figures for the CROPPED variant the util
no longer emits, and used them to steer users to `linear` — advice that is
backwards for the option now on offer.

Re-measured over 40 seeded pairs at int8, padded turbo vs this task's linear
path: 0.00034 vs 0.00269 at d=768, 0.00024 vs 0.00331 at d=1536, 0.00021 vs
0.00263 at d=3072. Padded turbo is 7.8x, 13.8x and 12.8x more accurate at exactly
the three sizes the old message named as reasons to avoid it.

- Adds a `turboPadToPowerOf2` input, DEFAULT FALSE. Enabling it lengthens the
  output vector (d -> nextPowerOf2(d)) and a storage column sized to d would
  reject the result, so it has to be opted into rather than inferred.
- Rewrites the rejection message to lead with the flag, state the widening and
  the column sizing, quote the real measurement, and name `linear` second as the
  option that preserves length. Same correction applied to the `method`
  description and to the util's JSDoc and throw, where the cropped figures are
  now explicitly labelled as the cropped variant's and marked as not to be quoted
  as the cost of turbo.
- Folds the duplicated `nextPowerOf2`: the util's copy calls `assertDimensions`
  first (rejecting non-integers, n < 1, n > 2**20) and the task's local copy did
  neither, so an oversized vector bypassed the task's carefully worded error and
  surfaced the low-level one. The util's helper is now exported and the local
  copy deleted.

Tests: a 768-dim vector with `turboPadToPowerOf2: true` returns length 1024 and
beats `method: "linear"` on measured RMSE over seeded pairs; the rejection test
additionally asserts the message names `turboPadToPowerOf2` and no longer
contains the stale 0.0164 figure. All three were confirmed RED against the base
branch before the fix.


Claude-Session: https://claude.ai/code/session_01RomTUtZSTgUbFCYqFs4pcu

Co-authored-by: Claude <noreply@anthropic.com>
…rently red)

The accuracy cases here all draw both vectors i.i.d. uniform at d=1024, where
the true cosine concentrates at 0 ± ~0.03 — the one regime where the
estimator's bias vanishes. The committed RMSE ceilings (0.033 @1bit,
0.016 @2bit) therefore cannot see it, and the module header claims the
similarity estimates are unbiased.

This measures MEAN SIGNED error on CORRELATED pairs
(`b = t*a + sqrt(1-t^2)*noise`, t in {0.5, 0.8, 0.95}, 32 seeded pairs per
cell). Signed rather than RMSE deliberately: RMSE scores a uniform 0.08
under-report and 0.08 of symmetric noise identically, and only the first
silently moves an absolute threshold. The reference is `cosineSimilarity(a, b)`
on the unquantized inputs, never `t` — the realized cosine of a finite sample
differs from `t` by ~1/sqrt(d), and scoring against `t` would fold that
sampling error into the measured bias.

Bands are the measured value ±30%, floored at ±0.005, and two-sided: this is a
record of behavior, not a ceiling.

It FAILS as committed, at 1 bit, by design — the next commit is the fix. Every
similar pair is under-reported: true 0.80 reads 0.59 at 1 bit (-0.208), 0.72 at
2 bits (-0.080), 0.78 at 3 (-0.027). A caller storing 2-bit codes for the
advertised 16x compression and keeping hits above cos > 0.8 drops matches whose
real cosine is 0.87.
At 1 bit the grid is exactly ±scale, so both reconstructions are
constant-magnitude sign vectors and the normalized dot product reduces to the
fraction of coordinates whose signs agree. Under a random rotation that
converges to `1 - 2*theta/pi` — a DIFFERENT function of the angle, not a noisy
cosine. It reads a true 0.80 as 0.59 and a true 0.95 as 0.79.

That one is exactly invertible, so `quantizedCosine` inverts it after the
clamp (`cos(pi*(1 - r)/2)`, the argument therefore always a valid angle). One
`Math.cos` per comparison, no change to any stored byte, no version bump — the
golden encodings are untouched.

Deliberately NOT extended to 2-8 bits. The shrinkage there is ordinary
quantization error with no closed form, and a fitted per-bit gain would be a
constant tuned at one dimensionality while the shrinkage varies with d — it
would correct one corpus and skew the next. The header documents it instead,
with the measured per-bit table, and says plainly that ranking survives the
bias while absolute thresholds and calibration do not.

Header corrections in the same pass: the claim that renormalization keeps "the
similarity estimates unbiased" was false (magnitude is unbiased; similarity is
not), the citation gains arXiv:2504.19874, and the first line now states that
the name refers to the borrowed rotation strategy — not the paper's quantizer
or its distortion bound.

THE TRADE, stated because it is a real one. The inversion amplifies whatever
noise the sign statistic carries by up to ~1.57x near theta = pi/2, and the
existing "should track the exact cosine within a per-bit-width RMSE ceiling"
case draws i.i.d. uniform pairs at d=1024, which land exactly there: it pays
the amplification in full and collects none of the bias correction. Its 1-bit
RMSE went 0.0273 -> 0.0356 and the ceiling follows, 0.033 -> 0.050, with the
reasoning recorded in the test rather than the number quietly moved.
Unbiased-but-noisier is the right side of that trade: the noise is symmetric
and averages out over a candidate set, and it is worst precisely where the
answer is "unrelated" either way, while the bias moved every absolute threshold
in one direction.

A new case pins the resulting asymmetry: the correction lives in
`quantizedCosine`, so `turboDequantize` + plain `cosineSimilarity` is NOT
angle-corrected at 1 bit. The two routes look interchangeable at every other
bit width, and a caller who decodes once to compare many times would otherwise
silently fall back to the biased estimator at the bit width where the bias is
largest.
…802)

The module ships two independent encoders and nothing said so.
`turboQuantizeToTypedArray` returns a plain Int8Array/Int16Array and drops into
any IVectorStorage backend; the four packed-codec functions return a
`TurboQuantizeResult` that no backend in this repo can accept, and none could
without a new column type.

Verified: `git grep` for the six packed-codec names and `TurboQuantizeResult`
across the branch, excluding the module and its own test, returns zero hits.
And the storage layer cannot take the shape even if a caller wanted to —
`assertVectorShape` (called by every backend on write and query) requires an
array-like whose length equals the declared dimensionality with every entry a
finite number, which a record fails outright and whose packed `codes` buffer
fails too (at 4 bits it holds two coordinates per byte). The backends that
score in-process all call `cosineSimilarity` on raw numbers with no hook for
`turboQuantizedCosineSimilarity`, and the pgvector-backed ones compute the
distance server-side where no client-side scorer can be injected at all.

Nothing is un-exported and no storage is wired. Un-exporting was considered and
rejected: the ~1200-line test suite imports those names from
`@workglow/util/schema`, so it would force a cross-package deep import into
`packages/util/src` (which nothing in `packages/test` does) or the deletion of
verified-correct code and its tests. Wiring storage needs a new column type, a
client-side scoring path, and per-backend fallbacks for the server-side-distance
engines — a multi-package feature, not a review fix.

So the fix is documentation: a module-header section naming which encoder has a
storage path, why the other cannot have one, what the packed record IS good for
(in-process comparison — a candidate cache, a client-side rerank over a
shortlist), and an explicit "do not persist it expecting a retrieval path to
exist". The real integration is tracked in #798, linked from the section.


Claude-Session: https://claude.ai/code/session_01UW1Qr5mxetAQr61YKEY9nz

Co-authored-by: Claude <noreply@anthropic.com>
* fix(util): correct TurboQuant's cosine shrinkage at 2-4 bits

`quantizedCosine` corrected only the 1-bit case, on the stated grounds that a
2-8 bit correction "would be a constant tuned at one dimensionality, and the
shrinkage varies with d". Both halves are false.

It is not a constant: the shrinkage varies with the cosine itself (4.5% between
rho=0.5 and rho=0.95 at 2 bits), which is why a scalar gain will not do and a
map is needed. And it does not vary with d: `getQuantizationParams` divides the
loading factor by sqrt(paddedLen), so the standardized grid is identical at
every dimensionality. Measured across a 32x range of d the ratio moves <=1.2%.

The quantity the estimator returns is E[Q(x)Q(y)]/E[Q(x)^2] for a standard
bivariate normal pair. `shrinkageAt` evaluates it by 1-D quadrature over the
conditional expectation E[Q(y)|x] -- a sum of normal CDFs, smooth, so Simpson
within each quantization bin resolves it -- and `invertShrinkage` inverts a
65-knot table of it by binary search. At 1 bit the same machinery reproduces
the Goemans-Williamson closed form the module already ships, to 2.8e-4, which
is what pins the quadrature.

Measured mean signed error, d=1024, 32 correlated pairs, rho=0.8:
  2 bits -0.0799 -> -0.0013     3 bits -0.0271 -> -0.0009
  4 bits -0.0075 -> +0.0009

5-8 bits are left alone: the residual bias is already at or below the
estimator's own noise (0.0030 vs 0.0034 at 5 bits) and the table build scales
as levels^2 (555 ms at 8 bits vs 22 ms at 4).

The cost is 1/g'(0) noise amplification near orthogonality: 1.13x at 2 bits
against the 1.57x the module already pays at 1 bit. RMSE ceilings at 2 and 3
bits follow, 0.016 -> 0.019 and 0.0085 -> 0.0090.

No stored bytes change and TURBO_QUANTIZE_VERSION is untouched -- the
code-to-value mapping is identical; only the estimator's final inversion moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UW1Qr5mxetAQr61YKEY9nz

* perf(util): add a prepared-query path for TurboQuant similarity search (#804)

`turboQuantizedCosineSimilarity` and `turboQuantizedInnerProduct` decode BOTH
sides on every call, so scoring one query against a candidate set unpacks and
reconstructs the query once per candidate -- at d=1024 and 20,000 candidates,
80,000 arrays and ~352 MB of churn, roughly half of it re-deriving coordinates
that never changed. The docstring said "without full dequantization", which is
true of the inverse rotation and false of the decode, and read as a licence to
use it in a search loop.

`turboPrepareQuery` decodes the query once; `turboPreparedCosineSimilarity`
decodes only the candidate. Measured over that sweep: 230 ms -> 105 ms at
8 bits (2.1x) and 180 ms -> 110 ms at 4 bits (1.6x). The corrected widths gain
less because the shrinkage inversion is a per-candidate cost neither path can
hoist.

The prepared path returns BIT-FOR-BIT what the pairwise helper returns, at
every width 1-8, and that is the contract the tests pin with `toBe`. Two things
make it hold. The shrinkage/angle correction is factored into a shared
`finishCosine`, so the two entry points cannot grow separate copies of it. And
the prepared query keeps its `codeNorm` SEPARATE rather than pre-dividing its
coordinates: pre-dividing turns `dot / (normA * normB)` into
`sum((a_i/normA) * b_i) / normB`, a different floating-point expression that
disagrees in the last bits on 96% of random pairs at d=1024. Numerically that
is nothing; as a contract it is everything, because it would mean adopting the
faster path silently moves every score in an index. Keeping the norm separate
costs one multiply per candidate against 1024 multiply-adds -- unmeasurable
(93 ms exact vs 96 ms pre-divided).

Compatibility is re-checked per candidate with the same three checks and the
same messages as `assertComparablePair`: a prepared query is a plain object a
caller can hold across a heterogeneous index, and the dot product would happily
consume a candidate from another rotation basis and return a number.


Claude-Session: https://claude.ai/code/session_01UW1Qr5mxetAQr61YKEY9nz

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
… int8 baseline (#801)

The "8x more accurate than linear" claim was measured against a defective
baseline. `VectorQuantizeTask`'s linear int8 path divides by the vector's L2
norm before scaling by 127, so on a well-spread vector every coordinate lands
near 1/sqrt(d) and the largest code it emits is 8 at d=768, 6 at d=1536, 4 at
d=3072 — three of its eight bits are gone before any comparison happens.
Beating it measured that defect, not the rotation.

Against a max-abs int8 quantizer (divide by max|v|, use the full code range),
padded turbo is 1.4x-1.8x WORSE on well-conditioned inputs. What the rotation
actually buys is independence from the input distribution: turbo stays in a
0.0001-0.0004 band whatever the input looks like, while max-abs swings with the
tail — on Gaussian vectors with 2 dimensions at 20x ("massive activations"),
turbo 0.00039 vs max-abs 0.00174 at d=768.

The module doc, the two rejection messages and the task's `method` schema
description now carry all three columns and say which is which. The cropping
paragraph keeps its figures but is re-anchored to padded turbo and max-abs
rather than to the L2 path.

`quantizeToInt8` itself is deliberately NOT repaired here — see #796. The
divisor change rescales every stored coordinate ~16x at d=768; cosine survives
it (positive scalar multiple) but `l2` and `ip` do not, and a stored int8
vector carries no marker of which scaling produced it. That is a
migration-relevant change and does not belong in an "add TurboQuant" PR.

Also in this change:
- `optimalLoadingFactor`'s "within 0.35%" was the 16-level figure quoted as if
  it were the worst; the real curve runs 4.03% at 2 levels down to ~0.12% at
  256. Replaced with the curve plus why it does not matter (the solver is only
  ever called at 255 and 65535 levels, past the fine end of the table).
- `nextPowerOf2` -> `turboPaddedLength`. The guard is right; the name promised a
  general-purpose helper while the function rejects anything over 2^20 with a
  message naming this module.
- `turboQuantizeToTypedArray`'s third parameter drops its default and its
  `number | Options` union. `{ seed: 42 }` alone was a compile error, since the
  interface uses `T | undefined` rather than `T?`. Breaking change to a function
  that has not shipped.
- `DEFAULT_SEED` is exported, so the task's schema default and destructuring
  default cite it instead of repeating the literal twice.
- New required output `originalDimensions`, so a consumer can tell a widened
  vector from a model that genuinely emits that many dimensions.

Tests: the accuracy test now scores against a max-abs reference computed in the
test file, so repairing the quantizer cannot move its bounds. A second case
pins the outlier-dimension claim; together they mean the corrected wording
cannot silently rot in either direction. A third records the shipped linear
path's max emitted code of 8 as a recorded defect, so its repair is a
deliberate, reviewed change.


Claude-Session: https://claude.ai/code/session_01UW1Qr5mxetAQr61YKEY9nz

Co-authored-by: Claude <noreply@anthropic.com>
@sroussey
sroussey force-pushed the claude/integrate-arxiv-paper-VF55c branch from 5e8cb99 to 7c525f0 Compare August 16, 2026 16:29
claude and others added 11 commits August 16, 2026 16:35
`normalizeToUnit` fixed the sumSquares accumulator overflow, but the norm is
reconstructed as `maxAbs * rootS` and that product still overflows to Infinity
for a large-magnitude FINITE input. `turboQuantize` returned it unvalidated,
because `assertQuantizeResultShape` runs only on read paths:

    turboQuantize(new Float64Array(4).fill(1e308), { bits: 8, seed: 42 })

yielded `norm: Infinity`, and `turboDequantize`, `turboQuantizedCosineSimilarity`
and `turboQuantizedInnerProduct` then all threw on it. The encoder was writing a
record nothing could read, and the failure surfaced three calls later phrased as
if the READER were malformed.

`turboQuantize` now rejects a non-finite norm with an encode-side message: it
names the largest absolute coordinate, states that the true L2 norm is not
representable as a double, and gives the two ways out — prescale the input
(cosine similarity is scale-free, so prescaling changes no similarity), or use
`turboQuantizeToTypedArray`, which discards the norm entirely and handles this
input unchanged.

The check is deliberately NOT in `normalizeToUnit`: `turboQuantizeToTypedArray`
calls it and destructures `values` only, so a 1e308 input is fine on that path
today and must stay fine. `normalizeToUnit` now also returns `maxAbs` so the
caller can report the magnitude that produced the overflow rather than only the
Infinity it became.

Also asserts `assertQuantizeResultShape` on the constructed record before
returning — the structural half of the same guarantee, that every record this
module hands out passes the check every reader applies. That holds today only by
coincidence, and the check is O(1) on already-computed scalars.

REJECTED: rescaling. There is nothing to rescale TO — the overflow means the
true L2 norm exceeds Number.MAX_VALUE and no double holds it. Representing it
would need a log-norm or (mantissa, exponent) field plus a
TURBO_QUANTIZE_VERSION bump, to serve inputs whose coordinates sit near 1e308.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lgxtp7mQECdh7F2UT9CVwN
The module doc claims "RANKING was never affected — ordering a candidate set was
correct before and is correct now". That conflates two claims, one true by
construction and one that had to be measured. These cases separate them, and
land before the doc correction so the recorded numbers are measured rather than
asserted.

Test A pins the half that is structural: `invertShrinkage` is monotone at every
corrected width, so switching the correction on or off moves every score and
moves none past another. A PIN, not a red test — it fails only against a
`shrinkageTable` whose quadrature is coarsened enough to make `g` non-monotone,
the one mechanism by which the corrected wording could rot. 1 bit is excluded:
its map is the closed form `cos(pi*(1 - r)/2)`, monotone by inspection, and
covered end-to-end by Test B. The map saturates to exactly ±1 past the tabulated
`g(1)` (2 bits from |r| >= 0.99, 3 bits from 0.995), a documented clamp — so the
assertion is "never decreasing, and strictly increasing wherever the value is
not pinned to an endpoint". A flat region ties scores; it does not invert them,
which is the distinction the doc claim rests on.

Test B measures the half that is not structural: how the estimator's ordering
compares with the exact cosine's, per bit width. Deterministic corpus at
d = 1024 (a power of two, so nothing is padded and the case measures the
estimator rather than the padding): 480 background documents, 10 planted at
t = 0.70..0.88 and 10 near-misses at t = 0.30..0.48. The 0.22 gap between the
10th and 11th planted document makes recall@10 insensitive to noise at every
width; the 0.02 spacing inside the top 10 makes ordering sensitive. The
reference ranking is `cosineSimilarity` on the unquantized vectors, never the
planted `t`.

Measured:

| bits | recall@10 | top-10 order identical | Kendall tau @ top 20 |
| ---- | --------- | ---------------------- | -------------------- |
| 1    | 1.00      | no                     | 0.9368               |
| 2    | 1.00      | yes                    | 0.9789               |
| 4    | 1.00      | yes                    | 1.0000               |
| 8    | 1.00      | yes                    | 1.0000               |

recall and order are pinned two-sided, which is what pins the corrected wording
in BOTH directions: "1 bit reorders the top 10" must fail if 1 bit ever stops
reordering it. Tau is a floor, non-decreasing in bits, so the case does not read
as flaky-shaped if the grid is retuned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lgxtp7mQECdh7F2UT9CVwN
…t shows

The module doc said ranking "was correct before and is correct now" and listed
"Preserves the RANKING induced by inner products and cosine similarity" as a
property. The first half of that is true and the second is not, and they were
being stated as one claim.

What is true: the shrinkage correction is strictly monotone, so it moves every
score and moves none past another — switching it on or off cannot change a
ranking. That half is kept, and now says explicitly that it is a statement about
the CORRECTION rather than about the estimator.

What is not: whether the estimator's ordering matches the exact cosine's is a
property of the quantizer, and at the low widths it is not perfect. The
Properties bullet now carries the per-width numbers measured by the preceding
commit, on the same corpus it cites:

| bits | recall@10 | top-10 order identical | Kendall tau over the top 20 |
| ---- | --------- | ---------------------- | --------------------------- |
| 1    | 1.00      | no                     | 0.9368                      |
| 2    | 1.00      | yes                    | 0.9789                      |
| 4    | 1.00      | yes                    | 1.0000                      |
| 8    | 1.00      | yes                    | 1.0000                      |

Retrieval of a shortlist is unaffected at every width; the order within it is
not. A recorded number per width, no prose adjective, matching the convention
the rest of this file already follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lgxtp7mQECdh7F2UT9CVwN
…cted

`turboDequantize` never said that comparing its output with a plain
`cosineSimilarity` yields the UNCORRECTED estimator at 1-4 bits. The warning
lived only on `turboPrepareQuery`, which a caller reaches after they already
understood the problem — while `turboDequantize` is the function on the natural
integration path, since a Float32Array is the only output shape an
IVectorStorage backend accepts.

Adds a section stating it: decode-then-compare reads a true 0.80 as 0.59 at
1 bit and low-but-less-so at 2-4; the corrected number comes from
`turboQuantizedCosineSimilarity` or `turboPrepareQuery` +
`turboPreparedCosineSimilarity`; at 5-8 bits the two routes are interchangeable,
and that boundary is `TURBO_MAX_CORRECTED_BITS`. Cross-links the test that pins
it.

Promotes the private `MAX_CORRECTED_BITS` to an exported
`TURBO_MAX_CORRECTED_BITS` so a caller can branch on the boundary
programmatically rather than reading a docstring and hoping. The internal name
stays as an alias, so there is one literal.

No "corrected-compare" helper, and the docstring says why: the correction
inverts a map defined on the CODE-DOMAIN ratio `dot / (codeNormA * codeNormB)`,
and a decoded Float32Array has already been renormalized to the recorded `norm`,
destroying that ratio — so any helper taking two TurboQuantizeResults and
returning a corrected number IS `turboQuantizedCosineSimilarity`. The real trap
(an IVectorStorage backend scoring decoded vectors server-side with no hook) is
not fixable in this module and is tracked as #798, which the module doc already
cites. The docstring says that rather than implying a workaround exists.

Test: exporting a constant creates a new way for docs and behavior to diverge,
so a case sweeps bits 1-8 on one correlated pair and requires the
corrected-vs-decoded gap to exceed 0.002 IFF `bits <= TURBO_MAX_CORRECTED_BITS`.
It fails if the constant is retuned without the correction being extended, and
equally in the other direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lgxtp7mQECdh7F2UT9CVwN
The dimensionality guard asks a PER-VECTOR question — is this length a power of
two? — about a BATCH-level invariant, so `[Float32Array(512),
Float32Array(1024)]` passed it unchanged. Each vector was then rotated in its
own `paddedLen` under its own sign table (`getSignTable(seed, paddedLen)` is a
different basis per length), while `originalDimensions` reported only
`vectors[0].length`. The same holds with `turboPadToPowerOf2: true` and lengths
700/1100, which land in 1024 and 2048.

Turbo output is comparable ONLY against output with the same seed and,
implicitly, the same padded length — so such a batch is not a batch, it is two
incomparable encodings returned as one array with metadata describing the first.

`executePreview` now rejects it. Turbo-only in this commit. An empty array
throws naming the input, where it previously reached `getVectorType(undefined)`
and threw "Unknown vector type: undefined". Otherwise the first vector whose
length differs is reported with its index, both lengths, both padded lengths,
the fact that the two live in different bases and are not comparable even after
padding, and the remedy: quantize each dimensionality in a separate call.

Tests (packages/test/src/test/rag/): the 512/1024 case, the 700/1100 case with
padding enabled — which proves the guard is not the existing power-of-2 check
under another name, since both inputs pass that check once padding is on — the
empty-array case, and an explicit re-assertion that a homogeneous turbo batch
still resolves. All three rejection cases fail against the pre-fix task.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lgxtp7mQECdh7F2UT9CVwN
Moves the guard before the `method` branch so it covers `linear` too, with a
method-aware consequence clause, and folds in the mixed ELEMENT-TYPE case.

Under linear the outputs keep their input lengths, so the break surfaces
downstream — `cosineSimilarity` throws on any pair of them — but
`originalDimensions` has already misreported before that, and a fixed-width
storage column accepts one output and rejects the other. Mixed element types are
the same defect on the other metadata field: `originalType` describes
`vectors[0]` alone, and that is the field a consumer needs to reverse the
quantization. Rejected for consistency with the dimensionality guard rather than
left to misreport.

THIS IS THE ONLY COMMIT IN THE BRANCH TOUCHING RELEASED BEHAVIOR.
`VectorQuantizeTask`'s array input predates #354, unlike the codec and the turbo
branch, so it is isolated here: a reviewer who objects drops exactly this commit
and the rest of the branch stays coherent.

The break is justified because it is from "silently wrong" to "loud". A caller
passing a heterogeneous batch today already receives a result whose
`originalDimensions` and `originalType` describe only `vectors[0]`, and whose
outputs cannot share a storage column or be compared with each other — there is
no correct use of that input shape. And there is no gradual path on offer
anyway: the input schema is `additionalProperties: false`, so an opt-out flag
cannot be added without the same kind of change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lgxtp7mQECdh7F2UT9CVwN
…d74u5n-turboquant-fixes

fix(util,ai): TurboQuant norm overflow, ranking claim, decode warning, heterogeneous batches
…ndidate

`turboPreparedCosineSimilarity` re-checked the three compatibility scalars
(bits, seed, dimensions) but nothing about the query's coordinates, while the
dot product is bounded by the CANDIDATE's padded length. A `values` shorter
than that read past its own end: every such read is `undefined -> NaN`, NaN
survives `finishCosine` to become the score, and a ranking sort over NaN is
implementation-defined rather than an error — so the failure surfaced as an
arbitrary shortlist with nothing reported. Three guards now reject it, placed
before the zero early-return so a malformed zero query cannot slip past.

Also make the shrinkage map exact at rho = 1. `shrinkageAt` cannot evaluate
that point — the `conditionalSd` floor turns the inner conditional expectation
into a step function landing on a bin boundary, so Simpson loses ~1.7% of the
cross term at 1 bit while `square` stays exact — and the tabulated last knot
came out at 0.983377 / 0.989762 / 0.994138 / 0.996838 at 1/2/3/4 bits against
a comment claiming g(1) = 1. Every ratio above that knot therefore collapsed
to exactly 1. Returning 1 analytically at rho = 1 (the pair is degenerate
there: y = x, so E[Q(x)Q(y)] IS E[Q(x)^2]) takes the worst disagreement with
the 1-bit Goemans-Williamson closed form from 2.776e-4 to 1.158e-4 on the
test's own grid, with the table still strictly increasing at all four widths.

No TURBO_QUANTIZE_VERSION bump: the shrinkage map is a scoring-time
computation, and the version covers only the codes -> reconstructed-values
mapping. Blast radius is bounded — `finishCosine` uses the closed form at
1 bit so no 1-bit score moves, and at 2-4 bits only ratios above the previous
last knot move, i.e. true cosines above ~0.9995. Self-similarity still
returns exactly 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJRf3YFa8DjmjsZvXz8xDT
`TurboQuantize.ts`'s module doc claims the typed-array encoder "drops into any
backend declared at the padded width", and nothing ran it. Three things were
asserted only in prose: that `turboQuantizeToTypedArray`'s output is a shape
`assertVectorShape` accepts entry by entry (the packed codec deliberately has
no storage path, so this is the only turbo encoder that does), that the length
is `turboPaddedLength(d)` and not `d` end to end, and that a padded vector
still retrieves its neighbour. A regression silently cropping or re-padding,
or an `assertVectorShape` change rejecting `Int8Array`, was invisible to every
other test in this area.

The round trip goes through `putBulk` (which is what runs
`validateVectorEntities`) into an `InMemoryVectorStorage` declared at 1024 for
a 768-dimensional embedding, then back out of `similaritySearch`. The
companion case executes the footgun four separate error messages in this
codebase warn about: a store declared at `d` rejects the widened vector on
write, which is why `turboPadToPowerOf2` defaults to false.

Added to the existing rag test file rather than a new one — it already owns
the task's turbo fixtures and timing hooks. The corpus is kept to ~25 vectors
because each one is a full workflow run, and it reuses `turboOf` rather than
building a second quantization path: the point is that what the TASK emits is
storable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJRf3YFa8DjmjsZvXz8xDT
…ublic disclaimer

Three documentation defects, each of which sends a reader the wrong way.

`turboQuantizedInnerProduct` said "for maximum accuracy, dequantize both sides
and take a real dot product". That is the UNCORRECTED estimator below
TURBO_MAX_CORRECTED_BITS and reads systematically LOW with nothing reported:
measured at d=1024 over 25 Gaussian pairs at true cosine 0.80, against a true
mean inner product of 816.8, this helper returns 823.8 while dequantize-then-
dot returns 611.2 at 1 bit (-25%), 739.7 at 2 bits (-9%) and 808.7 at 4 bits
(-1%). At 5-8 bits the two agree exactly, which is the boundary the constant
names. The docstring now says so and points a candidate-set caller at
`turboPrepareQuery` + `turboPreparedCosineSimilarity`.

The module doc's bias table is captioned "BEFORE the correction and as
shipped", but the 1-bit row's "before" column is already Goemans-Williamson-
corrected — that correction landed in an earlier commit than the 2-4 bit one.
Read as-is the table says 1 bit has no shrinkage to correct, while the code
says the raw statistic reads a true 0.80 as 0.59. The caption now names the
2-4 bit correction, the row is marked, and a note gives the genuinely
uncorrected 1-bit bias from the closed form: -0.167 at a true 0.50, -0.210 at
0.80, -0.152 at 0.95, roughly 200x the largest number in the table.

`VectorQuantizeTask`'s `method` schema description is the one UI-visible
surface, and it repeated the "TurboQuant" name with no disclaimer. It now
carries the same one the module doc does: the name refers to the borrowed
rotation strategy, not to the paper's distribution-fitted level placement,
which is not implemented.

Renaming the symbols is deliberately NOT done here and is filed as a
follow-up. `QuantizationMethod.TURBO`'s "turbo" is a persisted enum value in
serialized workflow JSON and in `VectorQuantizeTaskOutput.method`, and
`turboSeed` / `turboPadToPowerOf2` are named input ports — so a rename breaks
dataflow edges in saved graphs and invalidates cached task outputs. Deprecated
aliases cover function names but not port names or the persisted enum without
a migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJRf3YFa8DjmjsZvXz8xDT
… code array

`optimalLoadingFactor` solved the two level counts the signed typed-array path
reaches (255 for int8, 65535 for int16 — never powers of two, so absent from
the table) by ternary search over `quantizerDistortion`, which calls `Math.exp`
~1200 times per evaluation. ECMAScript leaves `Math.exp` implementation-
approximated, and this repo runs the same tests under JSC (`bun test`) and V8
(vitest under Node). Measured here the two disagree at 255 levels:
3.9206374677710735810 on Node v22.22.2 against 3.9206374677728756950 on Bun
1.3.11, a relative 4.6e-13. 65535 happens to agree. That is small enough to
produce the same bytes today and not small enough to guarantee it — a code is
`Math.round((clamped / scale) * max)`, and a value near a half-integer can flip
either way — so the golden-bytes test, which asserts an exact sequence derived
from this constant, was a weaker pin than it read.

Both values are now literals in `SOLVED_LOADING_FACTORS`, consulted via
`Object.hasOwn` (a bare index resolves inherited `Object.prototype` keys). The
ternary search stays as the fallback for any other level count. Tabulating the
exact doubles is preferred to rounding to N significant digits, which would
perturb `scale` and could silently change the checked-in bytes.

The golden-bytes test gains an INT16 block over the same fixture: the existing
int8 block pins the 255 literal, and nothing pinned 65535 at all. Both blocks
were run under vitest AND `bun test` and produce identical bytes.

Also drop the encoder's boxed `number[]` for a `Uint8Array` — safe by
construction, since `assertBits` caps `bits` at 8 so every code is in [0, 255],
and `packCodes` reads only `.length` and `codes[i]` (its signature widens to
`ArrayLike<number>`). `unpackCodes` already justifies this choice for the read
path. Measured peak RSS growth around a single `turboQuantize` at d = 2^20
falls from ~31 MB to ~25 MB, so `MAX_TURBO_DIMENSIONS`'s docstring figure is
re-measured rather than left stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJRf3YFa8DjmjsZvXz8xDT
…xes-354

Review fixes for PR #354 (TurboQuant): NaN-scoring prepared query, exact shrinkage endpoint, engine-pinned loading factors, corrected docs
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