Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 56 additions & 12 deletions .github/scripts/test_ci_workflow_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def selections(build, event):
]

artifacts = set()
verdicts = set()
for prefix, tag, check_name in (
("linux", "manylinux_x86_64", "Linux x86-64 wheels"),
(
Expand Down Expand Up @@ -131,21 +132,64 @@ def selections(build, event):
self.assertNotIn("continue-on-error", entry)
gate = job(workflow, f"{prefix}-wheels")
self.assertEqual(gate["name"], check_name)
self.assertEqual(needs(gate), [f"{prefix}-wheel-build"])
# The gate always runs so that a build skipped because the
# eligibility check failed cannot satisfy the required check.
self.assertEqual(
needs(gate), ["prepare", "changes", f"{prefix}-wheel-build"]
)
self.assertEqual(gate["if"], "${{ always() }}")
(verdict_step,) = steps(gate)
self.assertEqual(
verdict_step["env"]["BUILD_RESULT"],
f"${{{{ needs.{prefix}-wheel-build.result }}}}",
verdict_step["env"],
{
"PREPARE_RESULT": "${{ needs.prepare.result }}",
"BUILD_SELECTED": "${{ needs.prepare.outputs.build }}",
"CHANGES_RESULT": "${{ needs.changes.result }}",
"WHEELS_SELECTED": "${{ needs.changes.outputs.wheels }}",
"BUILD_RESULT": f"${{{{ needs.{prefix}-wheel-build.result }}}}",
},
)
for result in ("success", "failure", "cancelled", "skipped", ""):
with self.subTest(result=result):
verdict = subprocess.run(
["bash", "-e", "-c", verdict_step["run"]],
env=dict(os.environ, BUILD_RESULT=result),
capture_output=True,
check=False,
)
self.assertEqual(verdict.returncode == 0, result == "success")
verdicts.add(verdict_step["run"])
# One shared verdict script, exercised over every input combination.
(command,) = verdicts
results = ("success", "failure", "cancelled", "skipped", "")
for prepare in results:
for selected in ("true", "false", ""):
for changes in results:
for wheels in ("true", "false", ""):
for build in results:
if build == "success":
expected = True
elif build != "skipped":
expected = False
elif prepare == "skipped":
expected = True # excluded by the workflow_run filter
else:
expected = prepare == "success" and (
selected == "false"
or (changes == "success" and wheels == "false")
)
with self.subTest(
prepare=prepare,
selected=selected,
changes=changes,
wheels=wheels,
build=build,
):
verdict = subprocess.run(
["bash", "-e", "-c", command],
env=dict(
os.environ,
PREPARE_RESULT=prepare,
BUILD_SELECTED=selected,
CHANGES_RESULT=changes,
WHEELS_SELECTED=wheels,
BUILD_RESULT=build,
),
capture_output=True,
check=False,
)
self.assertEqual(verdict.returncode == 0, expected)

def test_installed_typing_checks_are_required(self):
config = tomllib.loads((ROOT / "pyproject.toml").read_text())
Expand Down
128 changes: 115 additions & 13 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,43 @@ jobs:
if-no-files-found: error

linux-wheels:
# Preserve the required check name and include every selected ABI.
# Preserve the required check name and include every selected ABI. The gate
# always runs: a skipped build passes only when nothing selected it, never
# because the eligibility check failed.
name: Linux x86-64 wheels
needs: linux-wheel-build
if: ${{ always() && !cancelled() && needs.linux-wheel-build.result != 'skipped' }}
needs: [prepare, changes, linux-wheel-build]
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require all selected wheels
env:
PREPARE_RESULT: ${{ needs.prepare.result }}
BUILD_SELECTED: ${{ needs.prepare.outputs.build }}
CHANGES_RESULT: ${{ needs.changes.result }}
WHEELS_SELECTED: ${{ needs.changes.outputs.wheels }}
BUILD_RESULT: ${{ needs.linux-wheel-build.result }}
run: test "$BUILD_RESULT" = success
run: |
if [ "$BUILD_RESULT" = success ]; then
exit 0
fi
if [ "$BUILD_RESULT" != skipped ]; then
exit 1
fi
# A skipped build passes when the eligibility check excluded this run,
# ran and selected no build, or a pull request changed nothing
# wheel-related. A failed eligibility check keeps the gate red.
if [ "$PREPARE_RESULT" = skipped ]; then
exit 0
fi
if [ "$PREPARE_RESULT" = success ] && [ "$BUILD_SELECTED" = false ]; then
exit 0
fi
if [ "$PREPARE_RESULT" = success ] && [ "$CHANGES_RESULT" = success ] &&
[ "$WHEELS_SELECTED" = false ]; then
exit 0
fi
exit 1

linux-arm64-wheel-build:
name: Linux ARM64 wheel builds
Expand Down Expand Up @@ -129,14 +156,39 @@ jobs:

linux-arm64-wheels:
name: Linux ARM64 wheels (CPython 3.11–3.14)
needs: linux-arm64-wheel-build
if: ${{ always() && !cancelled() && needs.linux-arm64-wheel-build.result != 'skipped' }}
needs: [prepare, changes, linux-arm64-wheel-build]
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require all selected wheels
env:
PREPARE_RESULT: ${{ needs.prepare.result }}
BUILD_SELECTED: ${{ needs.prepare.outputs.build }}
CHANGES_RESULT: ${{ needs.changes.result }}
WHEELS_SELECTED: ${{ needs.changes.outputs.wheels }}
BUILD_RESULT: ${{ needs.linux-arm64-wheel-build.result }}
run: test "$BUILD_RESULT" = success
run: |
if [ "$BUILD_RESULT" = success ]; then
exit 0
fi
if [ "$BUILD_RESULT" != skipped ]; then
exit 1
fi
# A skipped build passes when the eligibility check excluded this run,
# ran and selected no build, or a pull request changed nothing
# wheel-related. A failed eligibility check keeps the gate red.
if [ "$PREPARE_RESULT" = skipped ]; then
exit 0
fi
if [ "$PREPARE_RESULT" = success ] && [ "$BUILD_SELECTED" = false ]; then
exit 0
fi
if [ "$PREPARE_RESULT" = success ] && [ "$CHANGES_RESULT" = success ] &&
[ "$WHEELS_SELECTED" = false ]; then
exit 0
fi
exit 1

windows-wheel-build:
# Windows wheels take longest, so each ABI builds on its own runner.
Expand Down Expand Up @@ -174,14 +226,39 @@ jobs:

windows-wheels:
name: Windows x86-64 wheels
needs: windows-wheel-build
if: ${{ always() && !cancelled() && needs.windows-wheel-build.result != 'skipped' }}
needs: [prepare, changes, windows-wheel-build]
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require all selected wheels
env:
PREPARE_RESULT: ${{ needs.prepare.result }}
BUILD_SELECTED: ${{ needs.prepare.outputs.build }}
CHANGES_RESULT: ${{ needs.changes.result }}
WHEELS_SELECTED: ${{ needs.changes.outputs.wheels }}
BUILD_RESULT: ${{ needs.windows-wheel-build.result }}
run: test "$BUILD_RESULT" = success
run: |
if [ "$BUILD_RESULT" = success ]; then
exit 0
fi
if [ "$BUILD_RESULT" != skipped ]; then
exit 1
fi
# A skipped build passes when the eligibility check excluded this run,
# ran and selected no build, or a pull request changed nothing
# wheel-related. A failed eligibility check keeps the gate red.
if [ "$PREPARE_RESULT" = skipped ]; then
exit 0
fi
if [ "$PREPARE_RESULT" = success ] && [ "$BUILD_SELECTED" = false ]; then
exit 0
fi
if [ "$PREPARE_RESULT" = success ] && [ "$CHANGES_RESULT" = success ] &&
[ "$WHEELS_SELECTED" = false ]; then
exit 0
fi
exit 1

macos-wheel-build:
name: macOS arm64 wheel builds
Expand Down Expand Up @@ -218,14 +295,39 @@ jobs:
macos-wheels:
# Preserve the protected check name; PRs test the oldest and newest ABI.
name: macOS arm64 wheels (CPython 3.11–3.14)
needs: macos-wheel-build
if: ${{ always() && !cancelled() && needs.macos-wheel-build.result != 'skipped' }}
needs: [prepare, changes, macos-wheel-build]
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require all selected wheels
env:
PREPARE_RESULT: ${{ needs.prepare.result }}
BUILD_SELECTED: ${{ needs.prepare.outputs.build }}
CHANGES_RESULT: ${{ needs.changes.result }}
WHEELS_SELECTED: ${{ needs.changes.outputs.wheels }}
BUILD_RESULT: ${{ needs.macos-wheel-build.result }}
run: test "$BUILD_RESULT" = success
run: |
if [ "$BUILD_RESULT" = success ]; then
exit 0
fi
if [ "$BUILD_RESULT" != skipped ]; then
exit 1
fi
# A skipped build passes when the eligibility check excluded this run,
# ran and selected no build, or a pull request changed nothing
# wheel-related. A failed eligibility check keeps the gate red.
if [ "$PREPARE_RESULT" = skipped ]; then
exit 0
fi
if [ "$PREPARE_RESULT" = success ] && [ "$BUILD_SELECTED" = false ]; then
exit 0
fi
if [ "$PREPARE_RESULT" = success ] && [ "$CHANGES_RESULT" = success ] &&
[ "$WHEELS_SELECTED" = false ]; then
exit 0
fi
exit 1

release-tag:
name: Create version tag
Expand Down
7 changes: 5 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,13 @@ endif()
if(RABITQ_ENABLE_SANITIZERS)
# Keep sanitizer instrumentation consistent across public inline code and
# compiled components, including their runtime linkage.
# float-cast-overflow and float-divide-by-zero are not part of "undefined";
# quantization converts floats to integers, so enable them explicitly.
target_compile_options(rabitq_compile_options INTERFACE
"$<$<COMPILE_LANG_AND_ID:CXX,Clang,GNU>:-fsanitize=address,undefined;-fno-omit-frame-pointer>"
"$<$<COMPILE_LANG_AND_ID:CXX,Clang,GNU>:-fsanitize=address,undefined,float-cast-overflow,float-divide-by-zero;-fno-omit-frame-pointer>"
)
target_link_options(rabitq_compile_options INTERFACE
$<$<CXX_COMPILER_ID:Clang,GNU>:-fsanitize=address,undefined>
$<$<CXX_COMPILER_ID:Clang,GNU>:-fsanitize=address,undefined,float-cast-overflow,float-divide-by-zero>
)
endif()

Expand Down Expand Up @@ -281,6 +283,7 @@ install(DIRECTORY include/rabitqlib/core DESTINATION "${rabitq_include_dir}")
install(FILES
include/rabitqlib/utils/bitops.hpp
include/rabitqlib/utils/cpu_features.hpp
include/rabitqlib/utils/input_checks.hpp
include/rabitqlib/utils/memory.hpp
include/rabitqlib/utils/openmp.hpp
include/rabitqlib/utils/packed.hpp
Expand Down
23 changes: 18 additions & 5 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,14 @@ Clang:

It recompiles the library sources from `compile_commands.json` with that build's flags
(extra arguments such as `-O1` are appended) and fails when copies of a weak function
differ between ISA flag sets. Only the Release (`-O3`) configuration is clean and checked.
Lower optimization levels emit helpers the compiler otherwise inlines: Debug (`-O0`)
emits many standard-library and Eigen functions per source, and GCC at `-O1`/`-O2`
still shares two Eigen assignment loops between the FastScan estimator sources. The
script only sees `src/`, needs GNU binutils, and does not cover MSVC or ARM-only builds.
differ between ISA flag sets. CI checks the Release (`-O3`) configuration; with GCC 14
the check also passes at `-O1` and `-O2 -g`, which is what the sanitizer and
RelWithDebInfo configurations compile. Debug (`-O0`) still emits standard-library
helpers (`std::min`, `std::round`, `std::fill`) per source and is not clean. The FastScan
estimator correction is written as plain loops rather than Eigen expressions so that no
ISA source instantiates a shared Eigen helper; keep it that way, since a static library
only hides such sharing while its generic objects precede the ISA objects. The script
only sees `src/`, needs GNU binutils, and does not cover MSVC or ARM-only builds.

### Static analysis

Expand Down Expand Up @@ -331,6 +334,16 @@ Keep validation at the public operation boundary and reuse only invariants estab
for that operation. Buffer contents and per-record factors may change between calls:
validate them again even when reusing a query or workspace.

**Index inputs**

Every index entry point that accepts float data, centroids or queries calls
`check_coordinates` from `utils/input_checks.hpp` after its shape checks and before
mutating state: non-finite values and magnitudes above the clustering bound raise
`std::invalid_argument`. The core `quantize_*` encoders and `Lut` rely on that (the
LUT additionally rejects non-finite rotated queries) because quantization converts
floats to integers, which is undefined for NaN. Keep the check at the boundary rather
than inside per-vector kernels.

**Python conversion**

`ensure_*` returns a normalized NumPy owner retained across GIL release;
Expand Down
7 changes: 6 additions & 1 deletion docs/docs/index/ivf.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,12 @@ auto routing = ivf.initializer_type();
using twice the standard RaBitQ error margin, and recomputes exact distances
for the retained candidates. Returned distances are exact for those centroids, but
routing is approximate: pruning can miss a true nearest centroid.
- `hnsw` searches a graph of float32 centroids approximately.
- `hnsw` searches a graph of float32 centroids approximately. It suits workloads
whose `nprobe` is much smaller than the cluster count. Graph construction can
leave a few centroids unreachable, so points stored in those clusters are not
returned at any `nprobe`, and an `nprobe` close to the cluster count raises an
error instead of probing every cluster. Use `flat` or `flat_rabitq` when a
query should probe most clusters.

All choices support L2 and inner product. Flat RaBitQ uses the IVF-RaBitQ rotation and
padded domain, retains float32 centroids for refinement, and stores its mean and
Expand Down
8 changes: 8 additions & 0 deletions docs/docs/index/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ as the indexed data.
Arrays whose data pointers are not aligned for float32 are copied into aligned
storage before native processing; inputs remain unchanged.

Data, centroids and queries must be finite. Coordinates are also limited to the
magnitude the clustering APIs accept, `sqrt(sqrt(FLT_MAX) / (64 * dim))` (about
`5e7` for `dim = 128`), so that squared distances and their products stay inside
float32. `build`, `add` and `search` raise `ValueError` (C++:
`std::invalid_argument`) for NaN, infinite, or larger coordinates before changing
the index. The C++ `quantize_*` encoders in `core/encoder.hpp` assume their
callers have validated the input.

These limits apply to the Python indexes. The C++ [stable core API](../rabitq/core_api.md)
accepts working dimensions from 2, with multiples of 32 required for `SplitPacked`.
C++ [MatrixRotator](../rabitq/rotator.md) has separate dimension and storage requirements;
Expand Down
2 changes: 2 additions & 0 deletions include/rabitqlib/core/encoder.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ inline void quantize_compact_ex_bits(
cur_ex_data.f_rescale_ex() = f_rescale_ex;
}

// The per-vector encoders assume finite inputs; callers validate once per batch
// (see utils/input_checks.hpp) because quantization converts floats to integers.
inline void quantize_split_single(
const float* data,
const float* centroid,
Expand Down
5 changes: 5 additions & 0 deletions include/rabitqlib/fastscan/detail/lut.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include "rabitqlib/fastscan/detail/lut_prepare.hpp"
#include "rabitqlib/fastscan/fastscan.hpp"
#include "rabitqlib/fastscan/highacc_fastscan.hpp"
#include "rabitqlib/utils/input_checks.hpp"
#include "rabitqlib/utils/space.hpp"

namespace rabitqlib {
Expand Down Expand Up @@ -68,6 +69,10 @@ class Lut {
"Lut::reset requires uint16 scratch for high-accuracy lookup tables"
);
}
// Table quantization converts query-derived values to integers.
if (!all_finite(rotated_query, padded_dim)) {
throw std::invalid_argument("Lut::reset requires a finite query");
}
lut_.resize(shape.storage_length);
table_length_ = shape.table_length;
build_lut(rotated_query, padded_dim, lut_scratch, lut_u16_scratch, use_hacc);
Expand Down
Loading
Loading