Skip to content

Pin the HNSW routing test's graph, and make it prove the graph is worth testing - #2373

Open
kriszyp wants to merge 1 commit into
mainfrom
fix/hnsw-routing-test-graph-flake
Open

Pin the HNSW routing test's graph, and make it prove the graph is worth testing#2373
kriszyp wants to merge 1 commit into
mainfrom
fix/hnsw-routing-test-graph-flake

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 28, 2026

Copy link
Copy Markdown
Member

HNSW greedy routing above layer 0 (ROUTING_EF) — returns the same neighbours as searching every layer at the full ef compares a greedy descent against searching every layer at the full ef. HNSW assigns each node's level from an unseeded Math.random(), so every run built a different graph, and on a minority of those graphs greedy descent legitimately loses recall — the algorithm's behaviour, not the regression the test exists to catch. Measured locally: 3 failures in 45 runs of the file (~7%), with resolvedLayer0Ef constant at 110 across every run, pass and fail, so the graph was the only variable. It is red on main right now at c4dd96237 (Unit Test Node.js v24) and was red on the commit before it (Node.js v26), each time on a different target with different ids.

The fixture now pins the level stream, the way unitTests/resources/vectorIndexDeleteConnectivity.test.js:87-97 already does, and asserts the pin serves exactly one draw per node so another consumer cannot quietly reshape the graph.

Pinning creates the opposite hazard, and most of this diff is about not falling into it. On roughly 40% of graphs layer 0 is connected well enough that its own search reaches the true neighbours from any entry point; on those the comparison passes with the descent deleted outright. Seed 1 is such a graph. So the test now deletes the descent itself and requires the results to differ, and separately records the ef the layers above 0 actually receive and requires it to be ROUTING_EF. Without that second check the comparison also survives the optimization being removed: hand the upper layers the full ef in production and both sides of the comparison search identically, leaving a test named for greedy routing green while nothing routes greedily.

Fixes #2372

For the human reviewer

  1. This is not the PR the task pointed at. It was dispatched as a CI fix for Wait for the restart a component deploy triggers, and tell MQTT clients why a publish was refused #2341, which merged at 15:56Z with all 44 checks green — its two named failures had already been fixed on the branch (the final commit raised early-hints.test.ts's deploy budget to 90s). What is actually red is main, and this flake is one of the three reasons. The other two are below and are not addressed here.
  2. The seed is hand-picked, and the negative control is what makes that defensible. Seeds are not interchangeable: 12 of 20 make the fixture routing-sensitive and 8 do not. Rather than assert that in prose, the test asserts it executably — so a future change to N or the vector layout fails at the negative control with a clear message instead of going quietly green. Verified by deleting the descent in the product source: seeds 2–12 pass, seed 1 is rejected by the guard.
  3. The draws === N tripwire can produce a false failure, and the outside review flagged it twice. unitTests/resources/** runs single-process and non-parallel, and the realistic Math.random callers (resources/Table.ts:1320 auto-id — the test supplies explicit ids, :1450, :6669) are unreachable here; measured draws were exactly 600 in every run. The trade is deliberate: without the count, a stray draw reshapes the graph and silently restores a 7% flake; with it, the same event is one loud, named failure. Kept.
  4. Global Math.random monkeypatching, rather than an injectable RNG seam on the index. Gemini raised this as major twice. There is no injection point — the index calls Math.random() internally — and the global override is the established idiom in the sibling test and in benchmarks/hnsw-scale.js:82. A seam is the better long-term answer and gets harder to add the more tests depend on the patch; not attempted here.
  5. The negative control requires one of four targets to diverge, not all four. Requiring all four would over-constrain seed selection and become its own flake source. The primary invariant is still asserted on all four.
  6. Declined nit: one comment ("The comparison below holds either way if production stops passing ROUTING_EF here") was called review-response narration. It states why the assertion above it exists, which is the thing a reader would otherwise delete as redundant. Left as-is.
  7. after(() => T.dropTable()) is not awaited — a file-wide pattern in this test file, not introduced here, and left alone rather than changed under a flake fix. Noted so it is not lost.

Verification

The failure reproduces and the fix holds, both measured on the full file (npx mocha unitTests/resources/vectorIndex.test.js), because the flake does not appear when the describe is run alone (0/40):

build result
origin/main 3 failures / 45 runs (targets 0 and 3, different ids each time)
this branch 0 failures / 25 runs, then 0 / 25 again after the final revision

Both new assertions were checked against deliberately broken builds, not just asserted to work:

  • Negative control: with SEED swept 1–20, 12 seeds fail when upper-layer searchLayer is stubbed out in the product source and pass on the real build — the guard rejects seed 1 as insensitive and accepts 2–12.
  • ROUTING_EF check: replacing l === 0 ? effectiveEf : ROUTING_EF with effectiveEf in resources/indexes/HierarchicalNavigableSmallWorld.ts turns the test red with the layers above 0 must route at ROUTING_EF. The pre-existing comparison alone stays green through that change, which is why the check was added.

Gates: npm run test:unit:resources — 1767 passing / 0 failing / 22 pending. npm run test:unit:main — 5090 passing, 7 failing, all of them config/install-path assertions tripped by the unusually long local ROOTPATH (one is literally getDomainSocketPathLengthWarning); that gate excludes unitTests/resources/** and cannot reach this change. npm run lint:required and npm run format:write clean. test:integration:all not run: the change is a unit test in a suite the integration gate does not load.

Not fixed here — main is red for two more reasons

Both predate #2341 and neither is this flake:

Complexity: low

Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; declined=cursor-grok,cursor-composer; rounds=3 @ e07e60a

Human-Review-Need: 3 (decisions: negative-control-strength, rng-pinning-seam, hand-picked-seed, private-method-instrumentation) @ e07e60a

…th testing

`HNSW greedy routing above layer 0 (ROUTING_EF)` compares a greedy descent
against searching every layer at the full ef. HNSW level assignment draws from an
unseeded `Math.random()`, so every run built a different graph, and on a minority
of them greedy descent legitimately lost recall — a property of the algorithm,
not the regression the test exists to catch. It fails ~7% of the time (3/45
locally) and is currently red on main.

Pin the level stream, the way vectorIndexDeleteConnectivity.test.js already does,
and assert the pin serves exactly one draw per node so another consumer of the
stream cannot quietly shift the graph back.

A pinned graph raises the opposite problem: on roughly 40% of graphs layer 0 is
connected well enough that its own search reaches the true neighbours from any
entry point, and there the comparison passes even with the descent deleted
outright — seed 1 is such a graph. So the test now deletes the descent itself and
requires the results to differ.

It also records the ef the layers above 0 actually receive and requires it to be
ROUTING_EF. Without that the comparison survives the optimization being removed:
hand those layers the full ef in production and both sides of the comparison
search identically, leaving a test named for greedy routing green while nothing
routes greedily.

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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request updates the HNSW greedy routing unit tests in vectorIndex.test.js to ensure determinism and validity. It introduces a pinned seed for Math.random to construct a stable graph, asserts that exactly one level draw occurs per node, and verifies that routing above layer 0 occurs at ROUTING_EF. Additionally, it adds a negative control check to confirm that the generated graph actually requires greedy descent above layer 0 to reach the correct results. There are no review comments, so no feedback is provided.

@kriszyp
kriszyp marked this pull request as ready for review August 28, 2026 13:17
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

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.

Flaky unit test: HNSW greedy routing (ROUTING_EF) intermittently returns a shifted neighbour set

2 participants