Skip to content

fix(cluster): correct the vector geometry and stop truncating candidates - #30

Merged
StressTestor merged 1 commit into
mainfrom
fix/cluster-geometry
Jul 30, 2026
Merged

fix(cluster): correct the vector geometry and stop truncating candidates#30
StressTestor merged 1 commit into
mainfrom
fix/cluster-geometry

Conversation

@StressTestor

Copy link
Copy Markdown
Owner

Duplicate detection was finding a fraction of the duplicates on any corpus over 5000 items, which is every corpus this tool was built for.

On the 5285-item odysseus corpus: 26 clusters found where exact pairwise finds 501. The path that produced 26 was also slower, 20.6s against 9.3s.

Three bugs compounding, none of them visible from a diff.

1. the similarity filter was on the wrong scale

vec0 is declared with no distance metric, so sqlite-vec returns L2. store.search filtered 1 - distance >= threshold.

That is not cosine at any scale. For unit vectors L2 is sqrt(2 - 2cos), so a 0.85 threshold silently meant cosine 0.989. Every genuine near-duplicate below that was pruned before anything else got a look at it. It is also why every cluster i inspected had a minSimilarity of 0.98 or above, which i noted as a curiosity at the time rather than as the bug's signature.

2. nothing normalised on write

So the conversion was not valid to attempt in the first place. It appeared to work only because ollama returns unit vectors; for a provider that does not, the error would have been wrong in a model-dependent way, which is exactly the shape that corrupts a model comparison.

3. the candidate-limited path

cluster.ts routed 5000+ items through a path that passed the clustering threshold into that wrong-scale filter, then truncated whatever survived to K=50.

It was justified as an ANN trade-off. sqlite-vec has no ANN index: vec0 KNN is itself a full scan with a LIMIT (asg017/sqlite-vec#25). So it bought no speed and cost recall.

Deleted rather than tuned. Raising K while still passing a threshold into store.search reproduces the same bug at a larger limit and looks like a baseline. 5285 x 768 floats is milliseconds.

the migration

Stores written before this hold raw vectors. search() refuses them with an actionable message instead of answering with confidently wrong similarities. The check is on read, because the dangerous case is an old database opened by a new binary, which no insert-time assertion can see.

The backfill ships in this PR. A version guard without its migration turns every existing database into one that refuses to search. prism re-embed also stamps it, since the guard's error message says it does, and there is a test that fails if it stops being true.

also: incident-closed ranking

Incident-closed PRs now rank between open and closed rather than as fully open.

An item closed by a repository-wide event never got a maintainer verdict, so it has to outrank a deliberate close. But it is not evidence of live work the way an open PR is. The corpus is 993 open, 1347 merged, 2945 closed: promoting a ~900-item incident to open-equivalent roughly doubled the tier the ranking exists to order.

what this voids

Every cluster count published before this is void as comparative evidence. The model benchmark numbers measured near-exact-duplicate detection, not duplicate detection at the configured threshold.

The duplicates verified by hand against GitHub are still real duplicates. What is dead is the claim that one model finds them and another does not. Re-benchmarking has to happen under corrected geometry before any model default moves.

514 tests, lint, typecheck and build clean. Closes #19.

Duplicate detection was finding a fraction of the duplicates on any corpus over
5000 items, which is every corpus this tool was built for. Three bugs
compounding, none visible from a diff.

The vec0 table is declared with no distance metric, so sqlite-vec returns L2.
store.search filtered `1 - distance >= threshold`. That is not cosine at any
scale: for unit vectors L2 is sqrt(2 - 2cos), so a 0.85 threshold silently
meant cosine 0.989 and pruned every genuine near-duplicate below it. Nothing
normalised vectors on write either, so the conversion was not valid to attempt
in the first place - it worked only because ollama happens to return unit
vectors, and would have been wrong per-model for a provider that does not.

cluster.ts then routed 5000+ items through a candidate-limited path that passed
the clustering threshold into that same filter and truncated the survivors to
K=50. It was justified as an ANN trade-off, but sqlite-vec has no ANN index:
vec0 KNN is a full scan with a LIMIT. So it bought no speed and cost recall.

Measured on the 5285-item corpus that motivated the incident work: 26 clusters
where exact pairwise finds 501, taking 20.6s against 9.3s. The path is deleted
rather than tuned, because raising K while still passing a threshold into
store.search reproduces the same bug at a larger limit and looks like a
baseline.

Vectors are normalised on write, similarity is derived, and a geometry version
refuses to search a store written under the old representation instead of
answering it wrongly. The check is on read, since the dangerous case is an old
database opened by a new binary. Its backfill ships in this commit, not a later
one: a version guard without a migration turns every existing database into one
that refuses to search. `prism re-embed` also stamps it, because the guard's
error message says it does.

Separately, incident-closed PRs no longer rank as fully open. They now sit
between open and closed. Promoting them outright was an overcorrection: the
corpus is 993 open, 1347 merged, 2945 closed, so a ~900-item incident roughly
doubled the tier the ranking exists to order.

Every cluster count published before this is void as comparative evidence. The
duplicates verified by hand against GitHub are still real; the model rankings
built on these counts are not.
@StressTestor
StressTestor force-pushed the fix/cluster-geometry branch from d27aa85 to d2d8c40 Compare July 30, 2026 08:32

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d27aa85951

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/store.ts
);

this.db.prepare("DELETE FROM vec_items WHERE id = ?").run(id);
const unit = toUnitVector(item.embedding);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Guard clustering against mixed vector geometries

When an existing database is upgraded and a normal scan embeds new or changed items, this write normalizes only those vectors while untouched legacy vectors remain raw and the store stays marked as geometry 0. findDuplicateClusters() bypasses assertVectorGeometry() and reads both representations directly; although pairwise cosine is scale-invariant, its refinement step computes an arithmetic centroid for components larger than three items, so raw vectors are weighted by their original magnitudes and cluster membership depends on which items happened to be refreshed. Require a backfill/re-embed before normalized incremental writes are used for clustering, or enforce the geometry guard on clustering reads.

Useful? React with 👍 / 👎.

@StressTestor
StressTestor merged commit b5ad960 into main Jul 30, 2026
2 checks passed
@StressTestor
StressTestor deleted the fix/cluster-geometry branch July 30, 2026 17:26
@StressTestor StressTestor mentioned this pull request Jul 30, 2026
StressTestor added a commit that referenced this pull request Jul 30, 2026
Rewrites the accumulated [unreleased] entries as one coherent 4.0.0 entry, led
by an upgrading section rather than a feature list. The headline for anyone
already running pr-prism is not incident-aware ranking, it is that their
database refuses to search until it is converted.

Corrects text that went stale while the entries accumulated: an entry pointing
at "the vector-geometry fix below" when it sits above, star-map PR #11
described as still needing to land after it merged, server/triage.ts described
as still hand-rolling metadata after #29 fixed it, and incident-closed PRs
described as ranking "as open" after #30 moved them between open and closed.
#19 and #27 are both closed.

ARCHITECTURE described similarity.ts as "ANN pre-filtering, matryoshka
truncation". It contains neither, only cosineSimilarity and isZeroVector. That
was already wrong and the ANN path no longer exists at all.

Not corrected, deliberately: the README's "594 duplicate clusters on 6K+ items"
predates the candidate-limited path (added in v0.8), so it was measured under
exact comparison and is accurate again now. The matryoshka benchmark ran on
2000 items, below the old threshold, so it never took the broken path either.

Co-authored-by: StressTestor <StressTestor@users.noreply.github.com>
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.

vec0 ANN index uses sqlite-vec's default L2 metric while search() assumes cosine distance

1 participant