Skip to content

fix(transcripts): one clipped component was costing every vector a quarter of its norm - #186

Merged
dat999zx merged 3 commits into
dat999zx:mainfrom
williamttruong:fix/transcript-vector-quantization-clipping
Aug 25, 2026
Merged

fix(transcripts): one clipped component was costing every vector a quarter of its norm#186
dat999zx merged 3 commits into
dat999zx:mainfrom
williamttruong:fix/transcript-vector-quantization-clipping

Conversation

@williamttruong

Copy link
Copy Markdown
Contributor

Found while measuring direction 1 for #169. It is not related to that direction, and it invalidates some of the numbers I posted there — see the bottom.

One component clips, and it costs a quarter of the norm

quantizeScale clips at 6/sqrt(dims) on the argument that L2-normalised components sit near 1/sqrt(dims), so the threshold is about six sigma. True of the average component, false of the one that matters. granite-small-en-r2 — the shipped default — carries a rogue component near 0.70 against a threshold of 0.3062. Exactly one component of 384 clips, and that clip costs 25% of the norm and ~22° of direction.

Measured on 12 real messages through the default preset:

scheme mean ‖v‖ mean cos worst cos
fixed 6/sqrt(dims) (current) 0.7525 0.9236 0.9128
per-vector max 1.0003 0.99947 0.99940

The doc comment cites "a measured largest component of 0.327 and a p99.9 of 0.262". Whatever that was measured on, it was not this model.

Why nothing caught it

dotQuantized documents itself as cosine on the grounds that "both sides are unit-length". The stored side quietly was not. Every score was cos(q,d) · ‖d‖ with ‖d‖ ranging 0.69–0.81 across a live 9k-message archive — a content-independent ±8% reweighting of the ranking.

It also depressed the entire cosine scale. Top-1 scores on that archive:

before   0.47 – 0.59
after    0.82 – 0.83

against a MODEL_RELEVANCE_FLOORS entry of 0.76 for the same model. So any relevance floor measured on transcripts was measuring the clip, not the corpus.

The existing tests could not have caught this: a smooth synthetic vector sails through. Normalised Math.sin(i) has a max component near 0.07, a quarter of the threshold. The new fixture is shaped like a real embedding — one dominant dimension, 383 small ones — and asserts its max exceeds the old threshold, so it provably enters the clip path.

The fix, and why it is this small

A per-vector scale. scale is already a per-row column and dequantizeVector already multiplies by whatever the row carries, so there is no schema change, no reader change, and existing rows keep decoding exactly as they did.

The trade is resolution: scaling to the max spends int8 range on the rogue component and leaves the bulk coarser. The table above is that trade measured rather than argued. A p99.9 scale was tried alongside and is identical to six decimals, so the simpler rule wins.

Invalidating the stale rows

Old rows decode to norm ~0.75 and would rank systematically below faithfully-stored ones whatever they say, so the two must not share a corpus. Hence QUANTIZE_VERSION, folded into the transcript vector fingerprint.

Deliberately not EMBED_RECIPE_VERSION — that constant is about the text an atom becomes, and its own doc calls it a byte-identical cross-repo contract with knowl-cloud. Bumping it here would force a sync and a full knowledge reindex for a change that touches neither. Quantization is transcript-local, so its version is too.

No migration needed: embedPendingMessages already deletes rows whose fingerprint differs and treats "no vector for this fingerprint" as its resume point, so an ordinary knowl reindex --transcripts purges and rebuilds. Verified end to end on a copy of a real 9k-message archive — stored norms went from p50 0.7571 to p50 1.0005.

Test changes

Two tests reached past the public API and hand-passed a raw profile fingerprint to semanticRank; both now ask for the fingerprint the writer actually used. One of those mattered — "returns nothing semantically either" would otherwise have kept passing on a fingerprint mismatch rather than on the session scoping it exists to test, i.e. it would have silently stopped testing anything.

Transcript suite 351/351. Full suite is unchanged against upstream/main, which fails 80 tests in my environment for unrelated reasons (a @noble/hashes/argon2.js resolution error) — same count before and after.

What this means for #169

I re-ran the floor sweep on a correctly-quantized rebuild of the same archive. Your conclusion holds, and now holds on clean data — which is a stronger result than either of us had:

statistic real min technical max gap on-topic buried
top1 0.8230 0.8324 −0.0094 3/15 (20%)
top1 − top2 0.0007 0.0262 −0.0255 13/15 (87%)
top1 − mean(top10) 0.0083 0.0274 −0.0191 8/15 (53%)
top1 − median 0.0836 0.1113 −0.0277 2/15 (13%)
z 3.7946 4.6240 −0.8294 5/15 (33%)
CSLS 0.0301 0.0670 −0.0368 8/15 (53%)

Everything still overlaps. The margin still fails worst. CSLS — the lead I raised in #169 — also fails, 8/15 against raw cosine's 3/15, so that direction is closed too.

And a correction I owe you: my earlier comment on #169 claimed the knowledge floor "cannot be transplanted to transcripts" because transcript cosines span 0.47–0.59 against a 0.76 floor. That range was the clipping bug. Corrected, transcripts run 0.82–0.83 — above 0.76, so a ported floor would never fire rather than always fire. The recommendation is unchanged (don't port it), but my stated reason was wrong and I have corrected it there.

…arter of its norm

`quantizeScale` clips at `6/sqrt(dims)` on the argument that L2-normalised
components sit near `1/sqrt(dims)`, so the threshold is about six sigma. That
is true of the average component and false of the one that matters.
`granite-small-en-r2` -- the shipped default preset -- carries a rogue
component near 0.70 against a threshold of 0.3062. Exactly one component of
384 clips, and that single clip costs 25% of the norm and rotates the vector
by ~22 degrees.

Measured on 12 real messages through the default preset:

  | scheme               | mean ||v|| | mean cos | worst cos |
  | fixed 6/sqrt(dims)   |     0.7525 |   0.9236 |    0.9128 |
  | per-vector max       |     1.0003 |  0.99947 |   0.99940 |

Nothing detected it. `dotQuantized` documents itself as cosine on the grounds
that "both sides are unit-length", and the stored side quietly was not -- so
every score was `cos(q,d) * ||d||` with `||d||` ranging 0.69 to 0.81 across a
live 9k-message archive. That is a content-independent +/-8% reweighting of
the ranking, and it depressed the whole cosine scale: top-1 scores on that
archive ran 0.47-0.59 before this change and 0.82-0.83 after, against a
`MODEL_RELEVANCE_FLOORS` entry of 0.76 for the same model. Any floor measured
on transcripts was measuring the clip.

The existing tests could not have caught it. A smooth synthetic vector --
normalised `Math.sin(i)` has a max component near 0.07 -- sails through the
old scheme untouched. The new fixture is shaped like a real embedding, one
dominant dimension and 383 small ones, and asserts its max exceeds the old
threshold so it provably enters the clip path.

The fix is a per-vector scale. `scale` is ALREADY a per-row column and
`dequantizeVector` already multiplies by whatever the row carries, so there is
no schema change and no reader change, and existing rows keep decoding exactly
as they did. Scaling to the max spends int8 range on the rogue component and
leaves the bulk coarser; the table above is that trade measured rather than
argued. A p99.9 scale was tried alongside and is identical to six decimals, so
the simpler rule wins.

Stale rows are the remaining hazard: they decode to norm ~0.75 and would rank
systematically below faithfully-stored rows whatever they say, so the two must
not share a corpus. Hence `QUANTIZE_VERSION`, folded into the transcript vector
fingerprint. Deliberately NOT `EMBED_RECIPE_VERSION` -- that constant is about
the text an ATOM becomes and its doc calls it a byte-identical cross-repo
contract with knowl-cloud, so bumping it here would force a sync and a full
knowledge reindex for a change that touches neither. `embedPendingMessages`
already deletes rows whose fingerprint differs and treats "no vector for this
fingerprint" as its resume point, so an ordinary `knowl reindex --transcripts`
purges the old rows and rebuilds them. No migration.

Two tests reached past the public API and hand-passed a raw profile
fingerprint to `semanticRank`. Both now ask for the fingerprint the writer
actually used. One of them mattered: "returns nothing semantically either"
would otherwise have kept passing on a fingerprint mismatch rather than on the
session scoping it exists to test.

Transcript suite 351/351. Full suite unchanged against upstream/main, which
fails 80 tests in this environment for unrelated reasons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@dat999zx dat999zx left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Verified the premise independently rather than taking the table on trust, and it is exact. On this machine, granite-small-en-r2 embedding a real sentence:

dims=384  norm=1.0000  fixedThreshold=0.3062
top5 |components| = 0.7509, 0.1207, 0.1042, 0.0919, 0.0916
components over threshold: 1

One component of 384, at 2.45x the clip threshold, and the next largest is 6x below it. The 6-sigma argument assumes a spread this model does not have. Approving.

Three checks I ran beyond the diff:

  • No missed call sites. Every transcript_vectors fingerprint site on the branch routes through transcriptVectorFingerprintembed-pass (both the purge and the upsert), search, and nothing in federate/mcp-handlers reaches past it. A missed one would have matched zero rows silently, which is the failure mode this shape invites.
  • QUANTIZE_VERSION over EMBED_RECIPE_VERSION is right, and for a reason beyond the one you gave: there is a recorded constraint that EMBED_RECIPE_VERSION is a byte-identical cross-repo contract knowl-cloud holds a copy of, and bumping it forces a sync plus a full knowledge reindex. Transcript-local version for a transcript-local decision.
  • The zero-vector guard and the ±127 clamp both hold. With scale = max nothing can exceed the range by construction, and keeping the clamp for the rounding boundary is correct rather than defensive.

One stale comment

quantizeVector's doc ends with:

They stay clipped, though -- hence the EMBED_RECIPE_VERSION bump that makes an ordinary reindex replace them.

There is no EMBED_RECIPE_VERSION bump — that is the thing the PR deliberately does not do, and QUANTIZE_VERSION's own doc explains why at length. Left as is, it sends the next reader to the wrong constant to understand the invalidation.

Needs a rebase, one conflict

src/transcripts/search.ts conflicts with #185, which landed after you branched. It is a union: keep your transcriptVectorFingerprint(...) on the fingerprint assignment, keep the cosine/judgeRelevanceFloor work around it. Nothing semantic.

This PR exposes a live regression in #185, which is mine

Worth stating plainly because it changes the merge order. #185 applies embedder.relevanceFloor0.76, measured on knowledge atoms — to transcript hits. Measured against this repo's live 13MB archive on current main:

belowFloor  top-cosine  query
true        0.4936      zzzzz qqqqq xxxxx vvvvv wwwww
true        0.4798      unreal engine nanite lod streaming
true        0.4946      relevance floor cosine abstain      <- genuinely on-topic
true        0.4817      transcript search fusion            <- genuinely on-topic
true        0.5626      how do we do releases               <- genuinely on-topic

Every query fires, including the ones the archive genuinely answers. So today the typed tool prints NO CONFIDENT MATCH on every search, and worse, the search.transcripts.fallback chain — which I gave confidentOnly: true — withholds everything and reports a verified negative. That silently disables the fallback feature outright.

Your fix does not cause this; it masks it, by moving the scale to 0.82–0.83 where the same borrowed floor flips to never firing. Both directions are wrong, and your correction on #169 said exactly this before I measured it.

The root cause is mine and it is the same mistake per-model-floor.md warns about, one level up: that document established a floor cannot be borrowed across models, and I borrowed one across corpora. Transcript messages are not knowledge atoms and have no measured floor of their own.

So: this lands first, because any transcript floor measured before it would be measuring the clip. Then I fix #185 — withholding the verdict entirely until a floor is measured on corrected transcript data, rather than shipping a third wrong threshold. Filing that as its own issue so it does not ride on your PR.

Thanks for this one — and for the #169 correction, which was the thread I should have pulled myself.

…CIPE_VERSION

quantizeVector's doc ended by citing "the EMBED_RECIPE_VERSION bump that makes
an ordinary reindex replace them". There is no such bump -- not bumping it is
the deliberate choice QUANTIZE_VERSION's own doc explains twenty lines below,
because EMBED_RECIPE_VERSION is a byte-identical cross-repo contract with
knowl-cloud and bumping it would force a sync plus a full knowledge reindex for
a transcript-local change.

Comment only. Left as it was, it sends the next reader to the wrong constant to
understand how version 1 rows get invalidated.
@dat999zx

Copy link
Copy Markdown
Owner

Merged main into this branch and pushed, rather than rebasing — your commit stays intact and attributed. Two things I did on the branch, both visible in the history:

  • 86e422c — the stale comment from the review: quantizeVector's doc cited an EMBED_RECIPE_VERSION bump that does not exist. Comment only.
  • The main merge, which brought in fix(transcripts): the cosine was computed and then thrown away by the fusion #185. The search.ts overlap resolved cleanly as a union — transcriptVectorFingerprint(...) on the fingerprint assignment, the cosine/judgeRelevanceFloor work around it. Verified both survived rather than trusting the auto-merge, and that no profileFingerprint reaches a vector table any more.

Full suite on the combined tree: 362 files, 3519 passed, 5 skipped, exit 0, plus typecheck and docs:check. Since you noted you could not run it — the @noble/hashes/argon2.js failures you hit are environmental and known: crypto.argon2Sync landed in Node 24.7, so below that the Argon2id path falls back to pure JS and tests/cloud/send-transfer.test.ts blows its timeout. It is unrelated to anything here, and CI's node-24 leg is the one that exercises the native path.

Ready to merge once CI is green.

The floor regression I found is filed separately as #189, with the fix order: this lands first, because any transcript floor measured before it measures the clip rather than the corpus.

@dat999zx
dat999zx merged commit ff75ea7 into dat999zx:main Aug 25, 2026
7 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 25, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants