fix(util,ai): TurboQuant norm overflow, ranking claim, decode warning, heterogeneous batches - #814
Merged
sroussey merged 6 commits intoAug 16, 2026
Conversation
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||
sroussey
force-pushed
the
claude/integrate-arxiv-paper-VF55c
branch
from
August 16, 2026 16:29
5e8cb99 to
7c525f0
Compare
`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
force-pushed
the
claude/optimistic-goldberg-d74u5n-turboquant-fixes
branch
from
August 16, 2026 16:37
d74a298 to
70636ee
Compare
sroussey
merged commit Aug 16, 2026
86780fb
into
claude/integrate-arxiv-paper-VF55c
10 of 11 checks passed
sroussey
deleted the
claude/optimistic-goldberg-d74u5n-turboquant-fixes
branch
August 16, 2026 18:14
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked onto #354 (
claude/integrate-arxiv-paper-VF55c), which is the base of this PR — notmain. 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 doublenormalizeToUnitfixed the sumSquares accumulator overflow, but the norm is reconstructed asmaxAbs * rootSand that product still overflows to Infinity for a large-magnitude finite input.turboQuantizereturned it unvalidated, becauseassertQuantizeResultShaperuns only on read paths:turboDequantize,turboQuantizedCosineSimilarityandturboQuantizedInnerProductall 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 namesmaxAbs, 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 useturboQuantizeToTypedArray, which discards the norm entirely and handles this input unchanged.The check is deliberately not in
normalizeToUnit:turboQuantizeToTypedArraycalls it and destructuresvaluesonly, so a 1e308 input is fine on that path today and stays fine. Also assertsassertQuantizeResultShapeon 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_VALUEand no double holds it. Representing it would need a log-norm or(mantissa, exponent)field plus aTURBO_QUANTIZE_VERSIONbump, 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 showsThe 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-correctedturboDequantize's docstring never said its output carries the uncorrected estimator at 1-4 bits. That warning lived only onturboPrepareQuery, which a caller reaches only if they already understood the problem — whileturboDequantizeis the function on the natural integration path, since aFloat32Arrayis the only output shape anIVectorStoragebackend accepts. Adds a section: decode-then-compare with plaincosineSimilarityreads a true 0.80 as 0.59 at 1 bit and low-but-less-so at 2-4; the corrected number comes fromturboQuantizedCosineSimilarityorturboPrepareQuery+turboPreparedCosineSimilarity; at 5-8 bits the two routes are interchangeable, and that boundary isMAX_CORRECTED_BITS.Promotes the private
MAX_CORRECTED_BITSto an exportedTURBO_MAX_CORRECTED_BITS = 4so 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 decodedFloat32Arrayhas already been renormalized to the recordednorm, destroying that ratio — so any helper taking twoTurboQuantizeResults and returning a corrected number isturboQuantizedCosineSimilarity. The real trap (anIVectorStoragebackend 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 ownpaddedLenunder its own sign table (getSignTable(seed, paddedLen)is a different basis per length), whileoriginalDimensionsreported onlyvectors[0].length. Same withturboPadToPowerOf2: trueand 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 methodMoves the guard before the
methodbranch with a method-aware consequence clause (for linear: the outputs keep their input lengths, socosineSimilaritythrows downstream — butoriginalDimensionsmisreports before that, and a fixed-width storage column accepts one and rejects the other), and folds in the mixed element-type case, which misreportsoriginalType.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 iscosineSimilarityon the unquantized vectors, never the plantedt— a finite sample's realized cosine differs fromtby ~1/√d.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.
recallandorderIdenticalare 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.tsis 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 vitest— 54 files, 836 passed, 10 skipped (baseline before any edit, and again after).bun scripts/test.ts util rag vitest— 77 of 78 files passed, 1081 tests passed, 10 skipped, 1 failed. The one failure issrc/test/rag/EndToEnd.integration.test.tstiming 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, andTURBO_MAX_CORRECTED_BITSconfirmed to reach@workglow/util/schema(declaration emitted, and importing it through the package subpath resolves to4).bun run format— no changes to any file this branch touches (prettier --checkclean on all four). Prettier did rewrite 12 unrelated files carrying pre-existing drift on the base branch; those were reverted rather than folded in.should handle array of vectorsandshould handle array of vectors with turbo methodwere 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 vitestas the repo scripts do.Generated by Claude Code