Skip to content

fix(hnsw): search silently omitted stored rows (#773), plus post-merge follow-ups - #791

Open
ruvnet wants to merge 1 commit into
mainfrom
fix/hnsw-773-and-followups
Open

fix(hnsw): search silently omitted stored rows (#773), plus post-merge follow-ups#791
ruvnet wants to merge 1 commit into
mainfrom
fix/hnsw-773-and-followups

Conversation

@ruvnet

@ruvnet ruvnet commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Rebuilt cleanly on top of main rather than merging feat/implement-adr-273-275, which conflicted in 24 hunks across 5 files — almost all of it re-litigating content main already had from #774's squash merge. Verified equivalent: the only difference between this branch and the old one is 16 compiled NAPI binaries, 0 insertions and 0 deletions of source, and main's binaries are the newer ones.

The defect (#773)

search() silently omitted stored rows. Two distinct bugs in the insert path, both leaving points no efSearch could ever reach:

  1. An early return treated points_by_layer[layer] as that layer's membership. It isn't — that list records points whose maximum level is the layer, while a point of level L participates in every layer 0..=L. Bailing out linked the new point to nothing there, so two points both entering at level 1 got no layer-0 edges at all.
  2. The reciprocal edge was filed under the new point's own level instead of the layer being linked. Malkov Algorithm 1 puts bidirectional connections at layer lc. Using the point's level meant layer-0 back-edges landed on an upper layer, so the point held no in-edge on layer 0 — and the moment it stopped being the entry point, search dropped it permanently.

Verified as a negative control

Reverting the patch makes 3 of the 4 guards fail with VectorDB::search omitted stored rows in 3/30 trials; restoring it returns them to green. A regression test that has never been shown to fail is not a regression test.

The long-running scan (ignored by default, run once here) measures the invariant rather than asserting it: 0 of 25,000 trials returned an incomplete result set across five graph sizes, and a 12,000-point parallel build leaves no orphans.

Also included

  • bug(rvf-runtime): delete() bitmap is permanent — re-ingest does not undelete; compact drops re-ingested data #748 — re-ingesting a deleted identifier makes it live again. The tombstone described the vector the batch replaces, not the identifier, so the bitmap is cleared before the record is built and rolled back on every error path; otherwise the new vector was queryable but reclaimed as dead by the next compact().
  • Four post-merge follow-ups: hailo-backend audit CI, an ADR-009 correction (the aligned tail scan silently returns a stale manifest), npm version sync with the registry, and ruvllm 2.6.2 camelCase native field reads.

Tests

562 Rust tests green across the blast radius: ruvector-core 188, rvf-runtime 334, rvf-integration 40.

🤖 Generated with claude-flow

https://claude.ai/code/session_01WfyBMvexdN4bhWVaqSXKvR

Two distinct defects, both in the insert path, both leaving points that no
efSearch could ever reach again.

FIRST, an early return treated points_by_layer[layer] as the membership of
that layer. It is not: that list records points whose MAXIMUM level is the
layer, while a point of level L participates in every layer 0..=L. So an
empty list did not mean an empty layer, and bailing out made insert link the
new point to nothing there. Two points both entering at level 1 ended up with
no layer-0 edges at all.

SECOND, the reciprocal edge was filed under the new point own level instead
of the layer being linked. Malkov Algorithm 1 says bidirectional connections
go at layer lc. Using the point level meant a point entering at level >= 1
had its layer-0 back-edges recorded on an upper layer, so it held no in-edge
on layer 0. The moment it stopped being the graph entry point, nothing on
layer 0 pointed at it and search dropped it permanently.

VERIFIED AS A NEGATIVE CONTROL, not merely observed green: reverting the
patch makes 3 of the 4 guards fail with "VectorDB::search omitted stored rows
in 3/30 trials". Restoring it returns them to green. A regression test that
has never been shown to fail is not a regression test.

The long-running scan, ignored by default and run once here, measures the
invariant rather than asserting it: 0 of 25,000 trials returned an incomplete
result set across five graph sizes, and a 12,000-point parallel build leaves
no orphans.

Also carried in this working tree: rvf re-ingest of a deleted identifier now
makes it live again (issue #748). The tombstone described the vector the
batch replaces, not the identifier, so the bitmap is cleared before the
record is built and rolled back on every error path — otherwise the new
vector was queryable but reclaimed as dead by the next compact.

562 Rust tests green across the blast radius: ruvector-core 188, rvf-runtime
334, rvf-integration 40.

Co-Authored-By: claude-flow <ruv@ruv.net>
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.

1 participant