Skip to content

feat: Expose checkIntegrity() method to Python bindings#644

Open
jsbattig wants to merge 4 commits into
nmslib:masterfrom
LightspeedDMS:master
Open

feat: Expose checkIntegrity() method to Python bindings#644
jsbattig wants to merge 4 commits into
nmslib:masterfrom
LightspeedDMS:master

Conversation

@jsbattig

Copy link
Copy Markdown

Summary

Add check_integrity() method to Python Index class that validates HNSW graph structure.

Changes

  • Added new check_integrity() method to the Python bindings in python_bindings/bindings.cpp
  • Validates graph structure including:
    • Connection validity (no invalid neighbor IDs)
    • Self-loop detection
    • Duplicate connection detection
    • Orphan node detection (elements with no inbound connections)

Return Value

Returns a dictionary with:

  • valid: Boolean indicating if graph passed all checks
  • connections_checked: Total number of connections validated
  • element_count: Number of elements in the index
  • min_inbound: Minimum inbound connections for any node
  • max_inbound: Maximum inbound connections for any node
  • errors: List of error messages if any issues found

Use Case

This enables applications using hnswlib to perform health checks on HNSW indexes, detecting corruption or structural issues that could affect search quality.

Testing

The method has been tested with real HNSW indexes in the CIDX (Code Indexer) project.

🤖 Generated with Claude Code

Add check_integrity() method to Python Index class that validates HNSW
graph structure including:
- Connection validity (no invalid neighbor IDs)
- No self-loops
- No duplicate connections
- No orphan nodes (elements with no inbound connections)

Returns dict with: valid, connections_checked, element_count,
min_inbound, max_inbound, errors[]

This enables CIDX to perform health checks on HNSW indexes.
…c HNSW orphan repair

Adds Index<float>::repairOrphans() (bindings.cpp), following the same
pattern as the existing checkIntegrity() Python-friendly wrapper. For each
zero-inbound ("orphan") node, forces a back-edge from its own existing
level-0 neighbors, evicting a neighbor's current weakest edge only when
that eviction cannot itself create a new orphan (inbound count > 1 guard).
When an orphan's own local neighborhood offers no viable anchor (a
fragile-sub-clique lockup), falls back to a distance-sorted scan of the
whole graph -- a pigeonhole argument guarantees a safe candidate exists
somewhere since total inbound edges vastly exceed the element count.

Bounded to at most cur_element_count + 1 passes (provable termination).
Idempotent and deterministic under single-threaded construction; also
repairs the exact-tie race regime under multi-threaded construction.

Story #1358 / spike #1330 (code-indexer docs/research/hnsw-temporal-orphans-1330.md).
Code review finding on Story #1358: the inbound-counting loop in
repairOrphans() already bounds-checked neighbor ids before use
(`if ((size_t)data[j] < n) ...`), but the try_connect lambda used for the
anchor/victim connection logic did not apply the same guard:

- get_linklist0(anchor) where `anchor` is read out of a link list -- an
  out-of-range id would index outside cur_element_count (a write beyond
  max_elements is a true out-of-bounds write).
- inbound[cand] where `cand` is similarly sourced -- out-of-range
  std::vector::operator[] (undefined behavior) if that id is >= n.

Unreachable via the near-tie/exact-tie regimes alone (they only ever
produce zero-inbound nodes, never invalid-id connections), but S3's future
fleet sweep will invoke repair_orphans() against arbitrary real production
indexes, which could carry other corruption (torn-write scenarios).

Verified both were real, reachable crashes prior to this fix by directly
corrupting an on-disk index's link-list entries with out-of-range ids and
calling repair_orphans(): the anchor-id case segfaulted, the candidate-id
case aborted with a std::vector::operator[] out-of-range assertion.
Rebuilt with the fix: both scenarios now return gracefully with no crash.

Story #1358 / Epic #1333.
GitHub issue #1437 (LightspeedDMS/code-indexer): neither save_index() nor
load_index() released the Python GIL during the native file read/write +
graph (de)serialization, so every HNSW shard load blocked ALL Python
threads in the server process for the whole native call. Over NFS with
multi-hundred-MB/GB temporal shards this froze the code-indexer Web UI
and MCP front door for seconds to tens of seconds per query.

Fix: py::call_guard<py::gil_scoped_release>() on Index<float>::save_index
and Index<float>::load_index (bindings.cpp), matching the existing
gil_scoped_release pattern already used by add_items/knn_query in this
file. Only Index<float> is touched; BFIndex<float>'s save_index/load_index
are out of scope for this issue.

Concurrency safety analysis (loadIndex's `delete appr_alg; appr_alg = new
HierarchicalNSW(...)` reassignment): releasing the GIL for the whole call
means two Python threads calling load_index() concurrently on the SAME
Index object would race on this delete/reassign. Audited the actual
consumer (code-indexer's HNSWIndexManager.load_index() and
HNSWIndexCache.get_or_load()): every call site constructs a brand-new
hnswlib.Index() Python object before calling load_index() on it, and the
cache's per-key threading.Event single-flight sentinel guarantees only one
thread ever calls the loader (and thus load_index()) for a given cache key
at a time -- concurrent loads only ever happen for DIFFERENT keys, which
are always DIFFERENT Index objects. No code path in the consumer repo
calls load_index()/save_index() twice concurrently on the same Index
instance, so a defensive mutex inside loadIndex/saveIndex was judged
unnecessary and was not added, keeping the change minimal.

TDD: tests/python/bindings_test_gil_release.py builds a real ~300MB
on-disk index (500k x float32[128], M=8, ef_construction=40) and proves,
via a concurrent recorder-thread timestamp-gap technique, that a
background Python thread keeps making progress while save_index()/
load_index() run. Verified RED against the unfixed binding (recorder
silent for 90.8% of load_index()'s duration, 96.2% of save_index()'s) and
GREEN after the fix (gap ratio comfortably under the 0.5 threshold for
both). Full existing bindings_test*.py suite re-run clean: 16/16 passing
(14 pre-existing + 2 new), 92.9s, zero regressions.
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