Skip to content

Make graph-index tests independent of graph connectivity #216

Closed

Description

@gouyt13

Problem

HNSW-RaBitQ and SymphonyQG do not guarantee that every point is reachable. A
multi-threaded or unseeded build occasionally leaves a point with no incoming edge
(in-degree 0), and then no search can return it. Tests that expect every point to be found
therefore fail now and then in CI, although nothing is broken.

Seen so far:

  • HnswConstructionTest.ParallelConstructionProducesSearchableIndex
    (tests/unit/rabitqlib/index/hnsw_test.cpp, 8 construction threads) failed in CI run
    41220054028:
    the nearest result for point 2 was point 24. Since 671c6f1 it checks aggregate self-recall
    (at least 99%) instead of every point.
  • The qg case of test_indexes_accept_unaligned_build_and_query_arrays
    (tests/python/test_array_alignment.py) builds an unseeded SymqgIndex(65, 32) and
    searches with k=len(data), so all 65 points must be reachable. About 0.15% of random
    seeds give a graph that cannot reach every point; the rate is the same on 0.7.2.

Tests with the same pattern (an exact match for every point after a multi-threaded or
unseeded build) that have not failed in CI yet:

  • tests/python/test_thread_counts.py::test_build_and_search_thread_counts for hnsw and qg
    with build_threads 0 or the maximum: a parallel build (the qg case also unseeded), then
    expected_ids[:, 0] == np.arange(len(data)).
  • HnswAddTest.ParallelAddKeepsLabelsAndTheGraph in hnsw_test.cpp (8 threads for
    construction and add, then results[i][0].second == i for every point).

Task

Audit the graph-index tests in tests/unit/rabitqlib/index/ and tests/python/ and make each
one deterministic while keeping what it checks:

  • If the test checks exact results, bytes or every point (as the alignment test does), build
    with a fixed seed and one thread, which makes HNSW and SymphonyQG construction
    reproducible.
  • If the test is about parallel construction or thread counts, keep the threads and check an
    aggregate instead (self-recall above a threshold, as 671c6f1 does), or compare searches on
    the same graph only.

For the alignment test: with SymqgIndex(65, max_degree=32, ef_construction=32, seed=s) and
add(data, num_threads=1), aligned and unaligned inputs saved identical bytes and identical
k=5 results for 20 seeds × 3 offsets. Compare those, as the IVF and HNSW cases already do,
and drop the k=len(data) search.

Note for the test migration (#214): the 0.7 build defaulted to one thread, but add
defaults to all CPUs, so a migrated test that omits num_threads=1 becomes parallel and can
start failing this way.

Done when

  • No graph test asserts that every point is found after a multi-threaded or unseeded build.
  • Each changed test passes in a loop (for example 1,000 runs, or with
    ctest --repeat until-fail:1000 for C++) on a machine with several cores.
  • The changed tests still fail when the behavior they check breaks (for example a corrupted
    unaligned copy, or a search that returns wrong IDs).

Setup and checks: contribution guide
(Python changes,
C++ changes).

Activity

  1. added this to the 0.8.x milestone on Nov 2, 2026
  2. gouyt13 commented on Nov 12, 2026

    @gouyt13
    MemberAuthor

    More measurements on the qg case of test_indexes_accept_unaligned_build_and_query_arrays, on main at 2f5fedf (extension built from 0354810; no binding changes since), Linux, 96 CPUs, Python 3.13.

    The test fails on main at about the rate given above. Running test_concurrent_indexes.py, test_thread_counts.py and test_array_alignment.py together, 2 of 400 pytest runs failed, one qg case each (1,200 qg cases in total). A version migrated to the 0.8 API for #214 failed in 7 of 1,100 runs. Every failure had the reported symptom: one ID missing from the k=65 result, padded with 0xFFFFFFFF.

    Outside the test, I could not reproduce an unreachable graph with the same data, parameters and calls (SymqgIndex(65, max_degree=32, ef_construction=32), add(data, num_threads=1), search(queries, k=65, ef=65)):

    Setup Unreachable
    Seeds 0–19,999, one process 0 / 20,000
    Random seeds, one process (0.7 build and 0.8 add, 20,000 each) 0 / 40,000
    Random seeds, one process, after running the two threaded test files in the same process 0 / 20,000
    Random seeds, one build per fresh process 0 / 2,000
    seed=7, one build per fresh process, aligned input and unaligned input at offsets 1–3 0 / 750, all with identical saved bytes

    A seeded single-thread build is therefore deterministic across processes and input alignment, and for this data far fewer than 0.15% of seeds give an unreachable graph. The test failures do not look like a property of the seed alone. I have not found what else in the pytest run triggers them.

    Next step: when the qg assertion fails, record index.seed and rebuild that seed in a fresh process. If that graph reaches every point, the cause lies outside graph construction, for example in the search.

    Dropping the k=len(data) search, as proposed above, removes the failing assertion. Given these results, it is not clear that a fixed seed alone would prevent the failure.

  3. gouyt13 commented on Nov 12, 2026

    @gouyt13
    MemberAuthor

    Seen in CI on #227: test_indexes_accept_unaligned_build_and_query_arrays[2-qg] failed in the cp311 manylinux x86-64 wheel tests (job), with the same symptom: one ID missing from the k=65 result, padded with 0xFFFFFFFF. The rest of the suite passed (1 failed, 1109 passed, 2 skipped). #227 changes this case only to the 0.8 API: the constructor with ef_construction=32, then add(num_threads=1). The failing assertion is unchanged.

    This case was the 12th test in the pytest process. Before it, only the six k-means tests in the same file and five IVF/HNSW alignment cases ran, and no threaded index tests. A preceding threaded test is therefore not needed to trigger it.

  4. gouyt13 commented on Nov 12, 2026

    @gouyt13
    MemberAuthor

    #227 fixes two of the Python cases listed above. Both had failed in its CI before the fix: the qg alignment case, and test_build_and_search_thread_counts[18446744073709551615-l2-hnsw], where one point missed itself.

    • test_array_alignment.py: as proposed above, the qg case builds with seed=42 and one thread, and aligned and unaligned inputs must save identical bytes and return identical k=5 results. The k=len(data) search is removed, and the exact-distance check runs on the k=5 results.
    • test_thread_counts.py: IVF still requires every point to find itself. HNSW and SymphonyQG require a self-recall@1 of at least 95%, so up to 4 of the 96 points may miss. Searches on the same graph must still agree across thread counts. Locally, 6,000 graph builds on 96 CPUs and 8,000 parallel builds restricted to 4 CPUs had no misses, so the only miss observed is the one from CI.

    The two changed test files passed 1,000 repeated local runs (96 CPUs), with no failures. They still check what they are for: a corrupted unaligned copy changes the saved bytes, and wrong IDs from a multi-threaded search differ from the single-thread results. This follows from what the tests compare; I did not mutation-test them.

    Still open here: HnswAddTest.ParallelAddKeepsLabelsAndTheGraph in hnsw_test.cpp, and the audit of the other graph tests in tests/unit/rabitqlib/index/ and tests/python/.

  5. gouyt13 commented on Nov 12, 2026

    @gouyt13
    MemberAuthor

    Fixed by #228, which finishes the audit of the graph tests in tests/unit/rabitqlib/index/ and tests/python/. #227 had already fixed the qg alignment case and test_thread_counts.py. No graph test now requires every point after a multi-threaded or unseeded build.

    • Exact checks → fixed seed and one thread:
      • test_large_dimensions.py;
      • in test_symqg.py, the shared fixture and the rebuild case;
      • the SymphonyQG case of test_portable_indexes.py;
      • three test_hnsw.py cases whose add used the Python default of every CPU.
    • Parallel construction → keep the threads, check self-recall:
      • the example scripts (at least 95% of 128 points);
      • IVF HNSW routing over 1,024 centroids (at least 99%).
    • Still exact: HnswAddTest.ParallelAddKeepsLabelsAndTheGraph, listed above, keeps its exact check, as does the Python parallel-build test.
      • Both set M to the point count, so the base layer holds 2M ≥ N − 1 links and never prunes a reverse link; every point stays reachable under any thread interleaving.
      • An aggregate check would have let a build that drops reverse links pass.

    The changed tests passed 1,000-run loops on 96 CPUs and restricted to 4 CPUs, with no failures. They also passed the full CI matrix, including arm64, macOS and Windows.

    Still unexplained: the failure of the old qg alignment assertion described above. It failed about 0.2% of the time inside pytest, and never in separate processes. That assertion is gone, so it no longer affects CI. If a graph search turns out to miss reachable points, it will need its own issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinggood first issueGood for newcomers

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions