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
Open
Pin the HNSW routing test's graph, and make it prove the graph is worth testing#2373kriszyp wants to merge 1 commit into
kriszyp wants to merge 1 commit into
Conversation
…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>
Contributor
There was a problem hiding this comment.
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
marked this pull request as ready for review
August 28, 2026 13:17
Contributor
|
Reviewed; no blockers found. |
dawsontoth
approved these changes
Aug 28, 2026
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.
HNSW greedy routing above layer 0 (ROUTING_EF) — returns the same neighbours as searching every layer at the full efcompares a greedy descent against searching every layer at the full ef. HNSW assigns each node's level from an unseededMath.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%), withresolvedLayer0Efconstant at 110 across every run, pass and fail, so the graph was the only variable. It is red onmainright now atc4dd96237(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-97already 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
early-hints.test.ts's deploy budget to 90s). What is actually red ismain, and this flake is one of the three reasons. The other two are below and are not addressed here.Nor 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.draws === Ntripwire can produce a false failure, and the outside review flagged it twice.unitTests/resources/**runs single-process and non-parallel, and the realisticMath.randomcallers (resources/Table.ts:1320auto-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.Math.randommonkeypatching, rather than an injectable RNG seam on the index. Gemini raised this as major twice. There is no injection point — the index callsMath.random()internally — and the global override is the established idiom in the sibling test and inbenchmarks/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.ROUTING_EFhere") 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.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 thedescribeis run alone (0/40):origin/mainBoth new assertions were checked against deliberately broken builds, not just asserted to work:
SEEDswept 1–20, 12 seeds fail when upper-layersearchLayeris stubbed out in the product source and pass on the real build — the guard rejects seed 1 as insensitive and accepts 2–12.ROUTING_EFcheck: replacingl === 0 ? effectiveEf : ROUTING_EFwitheffectiveEfinresources/indexes/HierarchicalNavigableSmallWorld.tsturns the test red withthe 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 localROOTPATH(one is literallygetDomainSocketPathLengthWarning); that gate excludesunitTests/resources/**and cannot reach this change.npm run lint:requiredandnpm run format:writeclean.test:integration:allnot run: the change is a unit test in a suite the integration gate does not load.Not fixed here —
mainis red for two more reasonsBoth predate #2341 and neither is this flake:
Integration Tests 6/6 (Windows)—Component: redirector.deploy_componentexceeded undici's implicit 300sheadersTimeoutinsidesendOperation, surfacing as a bareTypeError: fetch failed. The same test took 294s (passing, 6s of margin) on the commit before Wait for the restart a component deploy triggers, and tell MQTT clients why a publish was refused #2341 and 95s two hours earlier — a slow-runner cliff, not a regression.npm packof a 28KB tarball took 67s on that runner. Any honest explicit budget is larger than the accidental 300s one, so it is a ceiling raise rather than a fix, and it belongs to@harperfast/integration-testing'ssendOperation, not this repo.Integration Tests 2/6—record-caching [rocksdb] 4-worker. Also failing pre-Wait for the restart a component deploy triggers, and tell MQTT clients why a publish was refused #2341.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