Skip to content

fix(util,ai): TurboQuant norm overflow, ranking claim, decode warning, heterogeneous batches - #814

Merged
sroussey merged 6 commits into
claude/integrate-arxiv-paper-VF55cfrom
claude/optimistic-goldberg-d74u5n-turboquant-fixes
Aug 16, 2026
Merged

fix(util,ai): TurboQuant norm overflow, ranking claim, decode warning, heterogeneous batches#814
sroussey merged 6 commits into
claude/integrate-arxiv-paper-VF55cfrom
claude/optimistic-goldberg-d74u5n-turboquant-fixes

Conversation

@sroussey

Copy link
Copy Markdown
Collaborator

Stacked onto #354 (claude/integrate-arxiv-paper-VF55c), which is the base of this PR — not main. Six commits, deliberately not squashed, so review and revert are granular where the risk is.

Commits

1. fix(util): reject a TurboQuant record whose norm overflows a double

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 })
// -> { norm: Infinity, ... }

turboDequantize, turboQuantizedCosineSimilarity and turboQuantizedInnerProduct all then threw on it — the encoder was writing a record nothing could read, and reporting it three calls later phrased as if the reader were malformed. It now throws at encode time, with a message that names maxAbs, states that the true L2 norm is not representable as a double, and gives the two ways out: prescale the input (cosine 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 stays fine. Also asserts assertQuantizeResultShape on the constructed record — the structural half, that every record this module hands out passes the check every reader applies, which today holds only by coincidence. O(1).

Rejected alternative: 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.

2. test(util): measure TurboQuant's ranking fidelity per bit width — lands before the doc commit so the recorded numbers are measured rather than asserted. See the table below.

3. docs(util): correct TurboQuant's ranking claim to what the measurement 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. Those are two claims and only the first is true by construction. The true half is kept and sharpened — the correction is strictly monotone, so it moves every score and moves none past another, and switching it on or off cannot change a ranking — and now says explicitly that this is a statement about the correction. The Properties bullet carries the measured per-width ordering fidelity instead of the false blanket claim.

4. docs(util): warn that turboDequantize's output is not shrinkage-corrected

turboDequantize's docstring never said its output carries the uncorrected estimator at 1-4 bits. That warning lived only on turboPrepareQuery, which a caller reaches only if 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: decode-then-compare with plain cosineSimilarity 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 MAX_CORRECTED_BITS.

Promotes the private MAX_CORRECTED_BITS to an exported TURBO_MAX_CORRECTED_BITS = 4 so a caller can branch on that boundary programmatically instead of reading a docstring and hoping. The internal name stays as an alias — 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 already tracked as #798, which the module doc cites. The docstring says that rather than implying a workaround exists.

New test: 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 — exporting a constant creates a new way for docs and behavior to diverge, and this fails in both directions.

5. fix(ai): reject a heterogeneous vector batch under method "turbo"

VectorQuantizeTask's 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 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. Same with turboPadToPowerOf2: true and lengths 700/1100 (→ 1024/2048). Turbo output is comparable only against output with the same seed and, implicitly, the same padded length.

Turbo-only in this commit. An empty array now throws naming the input (it previously reached getVectorType(undefined) and threw "Unknown vector type: undefined"). Otherwise the first differing vector 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.

6. fix(ai): reject a heterogeneous vector batch under every method

Moves the guard before the method branch with a method-aware consequence clause (for linear: the outputs keep their input lengths, so cosineSimilarity throws downstream — but originalDimensions misreports before that, and a fixed-width storage column accepts one and rejects the other), and folds in the mixed element-type case, which misreports originalType.

This is the only commit touching released behavior. VectorQuantizeTask's array input predates #354, unlike the codec and the turbo branch, so it is isolated so a reviewer who objects drops exactly this commit and the branch stays coherent. The justification: such a caller already receives a result whose originalDimensions/originalType describe only vectors[0] and whose outputs cannot share a storage column, so there is no correct use of that input shape — the break is from "silently wrong" to "loud". The input schema is additionalProperties: false with no opt-out, so there is no gradual path anyway.

Measured ranking fidelity (commit 2, quoted by commit 3)

Deterministic corpus, 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 true cosine 0.70-0.88 (0.02 apart) and 10 near-misses at 0.30-0.48. The reference ranking is cosineSimilarity on the unquantized vectors, never the planted t — a finite sample's realized cosine differs from t by ~1/√d.

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 planted set comes back whole even at 1 bit — while the order within it is not: 1 bit transposes documents inside the top 10, and 2 bits reproduces the top 10 exactly and still transposes a pair further down. The 0.22 gap between the 10th and 11th planted document makes recall@10 insensitive to noise at every width, while the 0.02 spacing inside the top 10 makes ordering sensitive — the two metrics are decoupled so neither sits on a knife edge. recall and orderIdentical are pinned two-sided, which is what pins the corrected wording in both directions; tau is a floor, non-decreasing in bits.

Compatibility

packages/util/src/vector/TurboQuantize.ts is added by #354, so every codec-level export is unreleased and commits 1-4 carry no compat risk — the behavior commit 1 replaces is "returns a record all three read paths throw on", so the throw merely moves earlier. Commit 6 is the only released-behavior change; see its note above.

Verification

  • bun scripts/test.ts util vitest54 files, 836 passed, 10 skipped (baseline before any edit, and again after).
  • bun scripts/test.ts util rag vitest77 of 78 files passed, 1081 tests passed, 10 skipped, 1 failed. The one failure is src/test/rag/EndToEnd.integration.test.ts timing out at 160s: an ONNX-download RAG pipeline test (3.6 GB RSS, onnx:Qwen3-Embedding-0.6B) that references nothing this branch touches. Run on its own it passes on both sides — 8/8 in 73s on this branch, 8/8 in 77s on the base commit — so it is a resource/timeout flake under the sweep's 40-worker parallel load, not a regression.
  • bun scripts/test.ts rag unit vitest — 21 files, 225 passed.
  • bun run build:types — 41/41 workspaces, and TURBO_MAX_CORRECTED_BITS confirmed to reach @workglow/util/schema (declaration emitted, and importing it through the package subpath resolves to 4).
  • bun run format — no changes to any file this branch touches (prettier --check clean on all four). Prettier did rewrite 12 unrelated files carrying pre-existing drift on the base branch; those were reverted rather than folded in.
  • The three commit-5 rejection cases were confirmed red against the pre-fix task before the fix landed, and the pre-existing homogeneous cases should handle array of vectors and should handle array of vectors with turbo method were verified green by name after commit 6.

Run with Node v22.22.2 (the container has no Node 24 available); everything went through bun / bunx vitest as the repo scripts do.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 60.07% 37630 / 62635
🔵 Statements 59.59% 39497 / 66276
🔵 Functions 60.98% 7255 / 11896
🔵 Branches 48.23% 19073 / 39538
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 #3164 for commit d74a298 by the Vitest Coverage Report Action

@sroussey
sroussey force-pushed the claude/integrate-arxiv-paper-VF55c branch from 5e8cb99 to 7c525f0 Compare August 16, 2026 16:29
claude added 6 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
@sroussey
sroussey force-pushed the claude/optimistic-goldberg-d74u5n-turboquant-fixes branch from d74a298 to 70636ee Compare August 16, 2026 16:37
@sroussey
sroussey merged commit 86780fb into claude/integrate-arxiv-paper-VF55c Aug 16, 2026
10 of 11 checks passed
@sroussey
sroussey deleted the claude/optimistic-goldberg-d74u5n-turboquant-fixes branch August 16, 2026 18:14
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.

2 participants