From 0a5f759222ca97f042c4523fb75db82eb34bffe7 Mon Sep 17 00:00:00 2001 From: gouyt13clear Date: Sat, 31 Oct 2026 04:44:32 +0800 Subject: [PATCH 1/3] test: trim redundant work and clean up temporary files - Fetch GoogleTest from its checksummed v1.14.0 release archive instead of cloning the repository in every configured build. - Add rabitq_test::TempFile, which removes a test's file and IVF sidecar even after a failed assertion, and use it for the index and I/O tests that cleaned up by hand. - Remove the shared float-comparison macro and random-vector generator, which had one caller each; use EXPECT_NEAR and a local generator. - Train QGKMeans at the exact 65536 maximum only with raw storage: its quantized rows share the 65536-wide padded layout of the 65535 cases. - Build the 20000-centroid historical HNSW IVF fixture for one storage width; both widths' legacy headers are still covered below it. --- tests/CMakeLists.txt | 9 ++- tests/common/temp_file.hpp | 39 ++++++++++ tests/common/test_data.cpp | 22 ------ tests/common/test_data.hpp | 19 ----- tests/common/test_helpers.hpp | 22 ------ tests/python/test_ivf_initializers.py | 8 ++- tests/python/test_qgkmeans.py | 2 +- .../rabitqlib/clustering/qgkmeans_test.cpp | 5 ++ .../index/flat_rabitq_initializer_test.cpp | 14 ++-- tests/unit/rabitqlib/index/hnsw_test.cpp | 34 +++++---- .../rabitqlib/index/index_counts_test.cpp | 13 ++-- .../unit/rabitqlib/index/initializer_test.cpp | 9 +-- tests/unit/rabitqlib/index/ivf_test.cpp | 71 +++++++++---------- tests/unit/rabitqlib/index/qg_test.cpp | 31 ++++---- .../quantization/xy_quantization_test.cpp | 34 +++++---- tests/unit/rabitqlib/utils/io_test.cpp | 13 ++-- tests/unit/rabitqlib/utils/rotator_test.cpp | 8 ++- 17 files changed, 171 insertions(+), 182 deletions(-) create mode 100644 tests/common/temp_file.hpp delete mode 100644 tests/common/test_data.cpp delete mode 100644 tests/common/test_data.hpp delete mode 100644 tests/common/test_helpers.hpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c9d8d636..8c90ed33 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,9 +1,12 @@ -# Fetch Google Test +# Fetch Google Test from its checksummed release archive rather than cloning the repository. include(FetchContent) +if(POLICY CMP0135) + cmake_policy(SET CMP0135 NEW) +endif() FetchContent_Declare( googletest - GIT_REPOSITORY https://github.com/google/googletest.git - GIT_TAG v1.14.0 + URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.tar.gz + URL_HASH SHA256=8ad598c73ad796e0d8280b082cebd82a630d73e73cd3c70057938a6501bba5d7 ) set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) set(INSTALL_GTEST OFF CACHE BOOL "" FORCE) diff --git a/tests/common/temp_file.hpp b/tests/common/temp_file.hpp new file mode 100644 index 00000000..37cbd207 --- /dev/null +++ b/tests/common/temp_file.hpp @@ -0,0 +1,39 @@ +#ifndef RABITQ_TEST_TEMP_FILE_HPP +#define RABITQ_TEST_TEMP_FILE_HPP + +#include + +#include +#include +#include + +namespace rabitq_test { + +// A file under the GoogleTest temporary directory that is removed, together with an IVF +// HNSW sidecar written next to it, when the test leaves its scope, including after a +// failed assertion. Leftovers from an interrupted earlier run are removed up front. +class TempFile { + public: + explicit TempFile(const std::string& name) : path_(::testing::TempDir() + name) { + remove(); + } + ~TempFile() { remove(); } + + TempFile(const TempFile&) = delete; + TempFile& operator=(const TempFile&) = delete; + + [[nodiscard]] const std::string& path() const { return path_; } + + private: + std::string path_; + + void remove() const noexcept { + std::error_code ignored; + std::filesystem::remove(std::filesystem::u8path(path_), ignored); + std::filesystem::remove(std::filesystem::u8path(path_ + ".hnsw"), ignored); + } +}; + +} // namespace rabitq_test + +#endif // RABITQ_TEST_TEMP_FILE_HPP diff --git a/tests/common/test_data.cpp b/tests/common/test_data.cpp deleted file mode 100644 index d5938467..00000000 --- a/tests/common/test_data.cpp +++ /dev/null @@ -1,22 +0,0 @@ -#include "test_data.hpp" - -#include -#include -#include - -namespace rabitq_test { - -std::vector TestDataGenerator::GenerateRandomVector( - size_t dim, float min, float max, unsigned int seed -) { - std::mt19937 rng(seed); - std::uniform_real_distribution dist(min, max); - - std::vector vec(dim); - for (size_t i = 0; i < dim; ++i) { - vec[i] = dist(rng); - } - return vec; -} - -} // namespace rabitq_test diff --git a/tests/common/test_data.hpp b/tests/common/test_data.hpp deleted file mode 100644 index 955e7028..00000000 --- a/tests/common/test_data.hpp +++ /dev/null @@ -1,19 +0,0 @@ -#ifndef RABITQ_TEST_DATA_HPP -#define RABITQ_TEST_DATA_HPP - -#include -#include - -namespace rabitq_test { - -class TestDataGenerator { - public: - // Generate random float vector with values in [min, max] - static std::vector GenerateRandomVector( - size_t dim, float min = -1.0f, float max = 1.0f, unsigned int seed = 42 - ); -}; - -} // namespace rabitq_test - -#endif // RABITQ_TEST_DATA_HPP diff --git a/tests/common/test_helpers.hpp b/tests/common/test_helpers.hpp deleted file mode 100644 index 4a68ed10..00000000 --- a/tests/common/test_helpers.hpp +++ /dev/null @@ -1,22 +0,0 @@ -#ifndef RABITQ_TEST_HELPERS_HPP -#define RABITQ_TEST_HELPERS_HPP - -#include - -#include - -namespace rabitq_test { - -// Floating point comparison with tolerance -inline bool FloatNearlyEqual(float a, float b, float epsilon = 1e-5f) { - return std::abs(a - b) < epsilon; -} - -#define EXPECT_FLOAT_NEARLY_EQUAL(a, b, epsilon) \ - EXPECT_TRUE(rabitq_test::FloatNearlyEqual((a), (b), (epsilon))) \ - << "Expected: " << (a) << " to be nearly equal to " << (b) \ - << " (epsilon: " << (epsilon) << "), but difference was " << std::abs((a) - (b)) - -} // namespace rabitq_test - -#endif // RABITQ_TEST_HELPERS_HPP diff --git a/tests/python/test_ivf_initializers.py b/tests/python/test_ivf_initializers.py index 78e28cf2..57c08f9e 100644 --- a/tests/python/test_ivf_initializers.py +++ b/tests/python/test_ivf_initializers.py @@ -164,10 +164,16 @@ def test_auto_initializer_thresholds(clusters, expected): assert IvfIndex(64, 1, clusters, 1, initializer=mode).initializer == mode +# Historical routing depends only on the cluster count, and both storage widths' +# legacy headers are covered below the threshold, so the costly 20000-centroid +# HNSW case is built for one width. @pytest.fixture( scope="module", params=[ - (bits, clusters) for bits in (1, 32) for clusters in (3, 5000, 19999, 20000) + (bits, clusters) + for bits in (1, 32) + for clusters in (3, 5000, 19999, 20000) + if (bits, clusters) != (1, 20000) ], ids=lambda case: f"{case[0]}bit-{case[1]}clusters", ) diff --git a/tests/python/test_qgkmeans.py b/tests/python/test_qgkmeans.py index d50f001b..af7deaf4 100644 --- a/tests/python/test_qgkmeans.py +++ b/tests/python/test_qgkmeans.py @@ -427,7 +427,7 @@ def test_training_size_warning_is_opt_in(capfd, verbose): assert ("please provide at least" in captured.err) == verbose -# C++ covers the full dimension/storage matrix; check binding outputs here. +# C++ covers each dimension/storage layout; check binding outputs here. @pytest.mark.parametrize("dim,bits", [(4097, 0), (65535, 4), (65536, 8)]) def test_large_dimensions(dim, bits): x = np.zeros((33, dim), dtype=np.float32) diff --git a/tests/unit/rabitqlib/clustering/qgkmeans_test.cpp b/tests/unit/rabitqlib/clustering/qgkmeans_test.cpp index aefd6390..a600ed28 100644 --- a/tests/unit/rabitqlib/clustering/qgkmeans_test.cpp +++ b/tests/unit/rabitqlib/clustering/qgkmeans_test.cpp @@ -1337,6 +1337,11 @@ TEST(QGKMeansTest, SupportsLargerPaddedDimensions) { data[i * d + d - 1] = static_cast(i) / 64.0F; } for (uint32_t bits : {0U, 4U, 8U}) { + // Quantized rows of 65535 and 65536 inputs share the 65536-wide padded + // layout; the exact maximum only adds unpadded raw storage. + if (d == 65536 && bits != 0) { + continue; + } QGKMeansParameters parameters; parameters.niter = 1; parameters.num_threads = 2; diff --git a/tests/unit/rabitqlib/index/flat_rabitq_initializer_test.cpp b/tests/unit/rabitqlib/index/flat_rabitq_initializer_test.cpp index 03bfd84f..aec78798 100644 --- a/tests/unit/rabitqlib/index/flat_rabitq_initializer_test.cpp +++ b/tests/unit/rabitqlib/index/flat_rabitq_initializer_test.cpp @@ -21,6 +21,7 @@ #include "rabitqlib/index/ivf/ivf.hpp" #include "rabitqlib/index/types.hpp" #include "rabitqlib/utils/space.hpp" +#include "temp_file.hpp" namespace rabitqlib::ivf { namespace { @@ -65,7 +66,8 @@ TEST(IvfInitializerTest, MatrixRotationAndExplicitRoutingRoundTrip) { data[kDim + 1] = 1; data[kDim * 2 + 2] = 1; const std::array labels{0, 1, 2}; - const std::string path = ::testing::TempDir() + "ivf_matrix_flat_rabitq.index"; + const rabitq_test::TempFile path_file("ivf_matrix_flat_rabitq.index"); + const std::string& path = path_file.path(); for (auto metric : {METRIC_L2, METRIC_IP}) { IVF index( kCount, @@ -89,7 +91,6 @@ TEST(IvfInitializerTest, MatrixRotationAndExplicitRoutingRoundTrip) { EXPECT_NEAR(distance, 0, 1e-5); } } - std::remove(path.c_str()); } TEST(IvfInitializerTest, ReloadChangesRoutingAndFailedLoadPreservesExistingIndex) { @@ -103,7 +104,8 @@ TEST(IvfInitializerTest, ReloadChangesRoutingAndFailedLoadPreservesExistingIndex for (size_t j = 0; j < kDim; ++j) data[i * kDim + j] = normal(generator); } - const std::string path = ::testing::TempDir() + "ivf_initializer_reload.index"; + const rabitq_test::TempFile path_file("ivf_initializer_reload.index"); + const std::string& path = path_file.path(); IVF target; for (auto type : {InitializerType::Flat, @@ -136,8 +138,6 @@ TEST(IvfInitializerTest, ReloadChangesRoutingAndFailedLoadPreservesExistingIndex EXPECT_EQ(actual_ids, expected_ids); EXPECT_EQ(actual_distances, expected_distances); } - std::remove(path.c_str()); - std::remove((path + ".hnsw").c_str()); } TEST(FlatRaBitQInitializerTest, TailBatchesMetricsAndExactReturnedDistances) { @@ -218,7 +218,8 @@ TEST(FlatRaBitQInitializerTest, PersistsCodesAndSupportsConcurrentSearch) { std::vector centroids(kDim * kCount), queries(8 * kDim); std::generate(centroids.begin(), centroids.end(), [&] { return normal(generator); }); std::generate(queries.begin(), queries.end(), [&] { return normal(generator); }); - const std::string path = ::testing::TempDir() + "flat_rabitq_initializer.bin"; + const rabitq_test::TempFile path_file("flat_rabitq_initializer.bin"); + const std::string& path = path_file.path(); for (auto metric : {METRIC_L2, METRIC_IP}) { FlatRaBitQInitializer index(kDim, kCount, metric), loaded(kDim, kCount, metric); index.add_vectors(centroids.data(), 2); @@ -262,7 +263,6 @@ TEST(FlatRaBitQInitializerTest, PersistsCodesAndSupportsConcurrentSearch) { std::ifstream after(path, std::ios::binary); EXPECT_EQ((std::string{std::istreambuf_iterator(after), {}}), bytes); } - std::remove(path.c_str()); } } // namespace } // namespace rabitqlib::ivf diff --git a/tests/unit/rabitqlib/index/hnsw_test.cpp b/tests/unit/rabitqlib/index/hnsw_test.cpp index 1dca9d79..c42ae916 100644 --- a/tests/unit/rabitqlib/index/hnsw_test.cpp +++ b/tests/unit/rabitqlib/index/hnsw_test.cpp @@ -26,6 +26,7 @@ #include "rabitqlib/index/types.hpp" #include "rabitqlib/utils/cpu_features.hpp" #include "rabitqlib/utils/space.hpp" +#include "temp_file.hpp" namespace rabitqlib::hnsw { @@ -334,8 +335,8 @@ TEST(HnswAddTest, SurvivesSaveAndLoad) { constexpr size_t kTotal = kBuilt + kAdded; AddFixture fixture(kTotal, 11); - const std::filesystem::path path = - std::filesystem::temp_directory_path() / "hnsw_add_roundtrip.index"; + const rabitq_test::TempFile path_file("hnsw_add_roundtrip.index"); + const std::filesystem::path path(path_file.path()); { HierarchicalNSW index(kTotal, AddFixture::kDim, 4, 8, 50); @@ -368,7 +369,6 @@ TEST(HnswAddTest, SurvivesSaveAndLoad) { for (size_t i = 0; i < kAdded; ++i) { EXPECT_EQ(results[i][0].second, kBuilt + i); } - std::filesystem::remove(path); } TEST(HnswAddTest, RejectsBadInputWithoutDisturbingTheIndex) { @@ -631,8 +631,8 @@ TEST(HnswResizeTest, SurvivesSaveAndLoad) { constexpr size_t kTotal = 96; AddFixture fixture(kTotal, 41); - const std::filesystem::path path = - std::filesystem::temp_directory_path() / "hnsw_resize_roundtrip.index"; + const rabitq_test::TempFile path_file("hnsw_resize_roundtrip.index"); + const std::filesystem::path path(path_file.path()); { HierarchicalNSW index(kBuilt, AddFixture::kDim, 4, 8, 50); index.construct( @@ -661,7 +661,6 @@ TEST(HnswResizeTest, SurvivesSaveAndLoad) { for (size_t i = 0; i < kTotal; ++i) { EXPECT_EQ(results[i][0].second, i); } - std::filesystem::remove(path); } // Labels must not depend on the order threads happen to finish in, and every @@ -975,11 +974,10 @@ Results without_removed(const Results& before, const std::vector& removed) std::unique_ptr copy_of( const HierarchicalNSW& index, const std::string& name ) { - const std::string path = ::testing::TempDir() + name; - index.save(path.c_str()); + const rabitq_test::TempFile file(name); + index.save(file.path().c_str()); auto copy = std::make_unique(); - copy->load(path.c_str()); - std::remove(path.c_str()); + copy->load(file.path().c_str()); return copy; } @@ -992,7 +990,8 @@ std::vector saved_bytes(const HierarchicalNSW& index, const std::string& p TEST(HnswConstructionTest, SeedReproducesRotationCodesAndSerialSearch) { constexpr size_t kCount = 65; AddFixture fixture(kCount, 47); - const std::string path = ::testing::TempDir() + "rabitq_hnsw_seed.index"; + const rabitq_test::TempFile path_file("rabitq_hnsw_seed.index"); + const std::string& path = path_file.path(); for (auto metric : {METRIC_L2, METRIC_IP}) { std::vector expected_bytes; Results expected_results; @@ -1019,7 +1018,6 @@ TEST(HnswConstructionTest, SeedReproducesRotationCodesAndSerialSearch) { } } } - std::remove(path.c_str()); } TEST(HnswRemoveTest, ExcludesRemovedPointsAndKeepsSurvivors) { @@ -1153,7 +1151,8 @@ TEST(HnswRemoveTest, SurvivesSaveLoadResizeAndLaterAdds) { constexpr size_t kBuilt = 96; constexpr size_t kAdded = 32; constexpr size_t kTotal = kBuilt + kAdded; - const std::string path = ::testing::TempDir() + "rabitq_hnsw_remove_roundtrip.index"; + const rabitq_test::TempFile path_file("rabitq_hnsw_remove_roundtrip.index"); + const std::string& path = path_file.path(); for (const auto& config : remove_cases()) { SCOPED_TRACE( ::testing::Message() @@ -1198,7 +1197,6 @@ TEST(HnswRemoveTest, SurvivesSaveLoadResizeAndLaterAdds) { without_removed(search_everything(*plain, fixture, kTotal), removed) ); } - std::remove(path.c_str()); } TEST(HnswRemoveTest, RejectsBadInputAndRemovesNothingOnFailure) { @@ -1218,7 +1216,8 @@ TEST(HnswRemoveTest, RejectsBadInputAndRemovesNothingOnFailure) { AddFixture fixture(kCount, 79); auto index = build_for_remove(fixture, kCount, kCount, {METRIC_L2, 4}); - const std::string path = ::testing::TempDir() + "rabitq_hnsw_remove_rejects.index"; + const rabitq_test::TempFile path_file("rabitq_hnsw_remove_rejects.index"); + const std::string& path = path_file.path(); const auto expected = saved_bytes(*index, path); const std::vector unknown{3, kCount}; @@ -1234,7 +1233,6 @@ TEST(HnswRemoveTest, RejectsBadInputAndRemovesNothingOnFailure) { // Label 3 was valid in the rejected call, so it is still there to remove. EXPECT_EQ(index->remove(&one, 1), 1U); - std::remove(path.c_str()); } TEST(HnswRemoveTest, RemovingEveryPointReturnsNoResults) { @@ -1272,7 +1270,8 @@ TEST(HnswRemoveTest, LoadRejectsAnOutOfRangeClusterEvenWithTheMark) { const PID one = 5; ASSERT_EQ(index->remove(&one, 1), 1U); - const std::string path = ::testing::TempDir() + "rabitq_hnsw_remove_bad_cluster.index"; + const rabitq_test::TempFile path_file("rabitq_hnsw_remove_bad_cluster.index"); + const std::string& path = path_file.path(); index->save(path.c_str()); HierarchicalNSW loaded; ASSERT_NO_THROW(loaded.load(path.c_str())); @@ -1286,7 +1285,6 @@ TEST(HnswRemoveTest, LoadRejectsAnOutOfRangeClusterEvenWithTheMark) { } catch (const std::runtime_error& error) { EXPECT_STREQ(error.what(), "HNSW: invalid or truncated index file"); } - std::remove(path.c_str()); } } // namespace diff --git a/tests/unit/rabitqlib/index/index_counts_test.cpp b/tests/unit/rabitqlib/index/index_counts_test.cpp index 93989af8..aa12a5fe 100644 --- a/tests/unit/rabitqlib/index/index_counts_test.cpp +++ b/tests/unit/rabitqlib/index/index_counts_test.cpp @@ -15,6 +15,7 @@ #include "rabitqlib/index/symqg/qg.hpp" #include "rabitqlib/index/symqg/qg_builder.hpp" #include "rabitqlib/index/types.hpp" +#include "temp_file.hpp" namespace rabitqlib { namespace { @@ -43,7 +44,8 @@ TEST(IndexCounts, IvfTracksRemovalGrowthReloadAndRebuild) { const std::vector centroid(kDim, 0.0F); const std::vector clusters(kCount + 4, 0); const std::array removed{1, kCount - 1, 1}; - const std::string path = ::testing::TempDir() + "rabitq_ivf_counts.index"; + const rabitq_test::TempFile path_file("rabitq_ivf_counts.index"); + const std::string& path = path_file.path(); for (const auto metric : {METRIC_L2, METRIC_IP}) { for (size_t bits : {1UL, 4UL, 32UL}) { SCOPED_TRACE(::testing::Message() << "bits=" << bits << " metric=" << metric); @@ -90,7 +92,6 @@ TEST(IndexCounts, IvfTracksRemovalGrowthReloadAndRebuild) { expect_counts(loaded, kCount + 4, kCount + 4, kCount + 4); } } - std::filesystem::remove(path); } TEST(IndexCounts, HnswSeparatesCapacityStoredLabelsAndActivePoints) { @@ -99,7 +100,8 @@ TEST(IndexCounts, HnswSeparatesCapacityStoredLabelsAndActivePoints) { std::vector clusters(kCount, 0); constexpr size_t kCapacity = 64; const std::array removed{1, kCount - 1, 1}; - const std::string path = ::testing::TempDir() + "rabitq_hnsw_counts.index"; + const rabitq_test::TempFile path_file("rabitq_hnsw_counts.index"); + const std::string& path = path_file.path(); hnsw::HierarchicalNSW index(kCapacity, kDim, 4, 8, 40); expect_counts(index, kCapacity, 0, 0); index.construct(1, centroid.data(), kCount, data.data(), clusters.data(), 1); @@ -137,12 +139,12 @@ TEST(IndexCounts, HnswSeparatesCapacityStoredLabelsAndActivePoints) { std::vector{kCount + 3} ); expect_counts(loaded, 2 * kCapacity, kCount + 4, 1); - std::filesystem::remove(path); } TEST(IndexCounts, SymphonyCountsOnlyCompletedBuilds) { const auto data = count_test_vectors(); - const std::string path = ::testing::TempDir() + "rabitq_symqg_counts.index"; + const rabitq_test::TempFile path_file("rabitq_symqg_counts.index"); + const std::string& path = path_file.path(); for (size_t bits : {0UL, 4UL, 8UL}) { symqg::QuantizedGraph index( kCount, kDim, 32, METRIC_L2, RotatorType::FhtKacRotator, bits, 42 @@ -165,7 +167,6 @@ TEST(IndexCounts, SymphonyCountsOnlyCompletedBuilds) { builder.refine(); expect_counts(index, kCount, kCount, kCount); } - std::filesystem::remove(path); } } // namespace } // namespace rabitqlib diff --git a/tests/unit/rabitqlib/index/initializer_test.cpp b/tests/unit/rabitqlib/index/initializer_test.cpp index a805c60a..2114960a 100644 --- a/tests/unit/rabitqlib/index/initializer_test.cpp +++ b/tests/unit/rabitqlib/index/initializer_test.cpp @@ -23,6 +23,7 @@ #include "rabitqlib/index/types.hpp" #include "rabitqlib/utils/space.hpp" #include "rabitqlib/utils/tools.hpp" +#include "temp_file.hpp" namespace rabitqlib::ivf { namespace { @@ -186,7 +187,8 @@ TEST(HNSWInitializerTest, ParallelInsertionPreservesCentroidLabelsAfterReload) { centroids[label * kDim + dim] = static_cast(label * kDim + dim); } } - const std::string path = ::testing::TempDir() + "rabitq_initializer_labels"; + const rabitq_test::TempFile path_file("rabitq_initializer_labels"); + const std::string& path = path_file.path(); HNSWInitializer initializer(kDim, kCount); initializer.add_vectors(centroids.data(), 4); std::ofstream ignored_output; @@ -206,7 +208,6 @@ TEST(HNSWInitializerTest, ParallelInsertionPreservesCentroidLabelsAfterReload) { ); } } - std::remove((path + ".hnsw").c_str()); } TEST(HNSWInitializerTest, ReloadsInnerProductGraphForRouting) { @@ -215,7 +216,8 @@ TEST(HNSWInitializerTest, ReloadsInnerProductGraphForRouting) { std::vector centroids(kCount * kDim, 0.0F); centroids[0] = 0.9F; centroids[kDim] = 100.0F; - const std::string path = ::testing::TempDir() + "rabitq_initializer_ip"; + const rabitq_test::TempFile path_file("rabitq_initializer_ip"); + const std::string& path = path_file.path(); HNSWInitializer initializer(kDim, kCount, METRIC_IP); initializer.add_vectors(centroids.data(), 4); std::ofstream ignored_output; @@ -230,7 +232,6 @@ TEST(HNSWInitializerTest, ReloadsInnerProductGraphForRouting) { loaded.centroids_distances(query.data(), 1, candidates); EXPECT_EQ(candidates[0].id, 1U); EXPECT_FLOAT_EQ(candidates[0].distance, -99.0F); - std::remove((path + ".hnsw").c_str()); } TEST(HNSWInitializerTest, PerCallSearchWindowsPreserveDefaultAndLegacyResults) { diff --git a/tests/unit/rabitqlib/index/ivf_test.cpp b/tests/unit/rabitqlib/index/ivf_test.cpp index ba13e93f..c1eaf3fe 100644 --- a/tests/unit/rabitqlib/index/ivf_test.cpp +++ b/tests/unit/rabitqlib/index/ivf_test.cpp @@ -27,6 +27,7 @@ #include "rabitqlib/index/detail/buffer.hpp" #include "rabitqlib/index/ivf/initializer.hpp" #include "rabitqlib/index/types.hpp" +#include "temp_file.hpp" namespace rabitqlib::ivf { @@ -413,7 +414,8 @@ TEST(IvfPersistenceTest, ReloadsRawAndQuantizedStorage) { std::vector data(kNum * kDim, 1.0F); std::vector centroid(kDim, 0.0F); std::vector clusters(kNum, 0); - const std::string path = ::testing::TempDir() + "rabitq_ivf_storage.index"; + const rabitq_test::TempFile path_file("rabitq_ivf_storage.index"); + const std::string& path = path_file.path(); IVF loaded; for (auto rotator : {RotatorType::FhtKacRotator, RotatorType::MatrixRotator}) { for (size_t bits : {32UL, 1UL, 2UL, 3UL, 4UL, 5UL, 6UL, 7UL, 8UL, 9UL}) { @@ -436,7 +438,6 @@ TEST(IvfPersistenceTest, ReloadsRawAndQuantizedStorage) { EXPECT_EQ(distances, loaded_distances); } } - std::remove(path.c_str()); } TEST(IvfPersistenceTest, FailedLoadPreservesExistingIndex) { @@ -448,7 +449,8 @@ TEST(IvfPersistenceTest, FailedLoadPreservesExistingIndex) { std::fill( target_data.begin() + static_cast(kTargetDim), target_data.end(), 2.0F ); - const std::string path = ::testing::TempDir() + "rabitq_ivf_truncated.index"; + const rabitq_test::TempFile path_file("rabitq_ivf_truncated.index"); + const std::string& path = path_file.path(); IVF target(kTargetNum, kTargetDim, 1, 32, METRIC_IP, RotatorType::MatrixRotator); target.construct( @@ -500,8 +502,6 @@ TEST(IvfPersistenceTest, FailedLoadPreservesExistingIndex) { ); EXPECT_EQ(actual_ids, expected_ids); EXPECT_EQ(actual_distances, expected_distances); - - std::remove(path.c_str()); } TEST(IvfPersistenceTest, RejectsPointIdsUsingTheSearchBufferMarker) { @@ -510,7 +510,8 @@ TEST(IvfPersistenceTest, RejectsPointIdsUsingTheSearchBufferMarker) { std::vector data(kNum * kDim, 0.25F); std::vector centroid(kDim, 0.0F); std::vector clusters(kNum, 0); - const std::string path = ::testing::TempDir() + "rabitq_ivf_bad_id.index"; + const rabitq_test::TempFile path_file("rabitq_ivf_bad_id.index"); + const std::string& path = path_file.path(); IVF source(kNum, kDim, 1, 4); source.construct(data.data(), centroid.data(), clusters.data(), false, 1); @@ -525,7 +526,6 @@ TEST(IvfPersistenceTest, RejectsPointIdsUsingTheSearchBufferMarker) { IVF loaded; EXPECT_THROW(loaded.load(path.c_str()), std::runtime_error); - std::remove(path.c_str()); } // ── add / remove ──────────────────────────────────────────────────────────────────────── @@ -613,7 +613,8 @@ TEST(IvfLifecycleTest, BatchTailSurvivesAddRemoveAndReload) { const std::vector centroid(kDynDim, 0.0F); const std::vector assigned(kCount, 0); const std::array removed{0, kCount - 1}; - const std::string path = ::testing::TempDir() + "rabitq_ivf_lifecycle.index"; + const rabitq_test::TempFile path_file("rabitq_ivf_lifecycle.index"); + const std::string& path = path_file.path(); for (auto metric : {METRIC_L2, METRIC_IP}) { for (size_t bits : {1U, 4U, 9U, 32U}) { for (bool hacc : {false, true}) { @@ -685,7 +686,6 @@ TEST(IvfLifecycleTest, BatchTailSurvivesAddRemoveAndReload) { } } } - std::remove(path.c_str()); } TEST(IvfAddTest, EveryWayOfAddingTheSamePointsGivesByteIdenticalIndexes) { @@ -694,8 +694,10 @@ TEST(IvfAddTest, EveryWayOfAddingTheSamePointsGivesByteIdenticalIndexes) { const auto points = random_rows(kOld + kNew, 11); const auto centroids = random_rows(kDynClusters, 12); const float* extra = points.data() + (kOld * kDynDim); - const std::string base_path = ::testing::TempDir() + "rabitq_ivf_add_base.index"; - const std::string path = ::testing::TempDir() + "rabitq_ivf_add.index"; + const rabitq_test::TempFile base_path_file("rabitq_ivf_add_base.index"); + const std::string& base_path = base_path_file.path(); + const rabitq_test::TempFile path_file("rabitq_ivf_add.index"); + const std::string& path = path_file.path(); for (auto metric : {METRIC_L2, METRIC_IP}) { const auto assigned = @@ -764,8 +766,6 @@ TEST(IvfAddTest, EveryWayOfAddingTheSamePointsGivesByteIdenticalIndexes) { EXPECT_TRUE(saved_bytes(threaded, path) == expected) << "threaded"; } } - std::remove(base_path.c_str()); - std::remove(path.c_str()); } TEST(IvfAddTest, AddingToOneClusterLeavesOtherClustersUntouched) { @@ -812,7 +812,8 @@ TEST(IvfAddTest, SparseInterleavedClustersPreserveRowOrderAndMatchRebuild) { const auto points = random_rows(kCount, 744); const auto centroids = random_rows(kClusters, 745); const std::array assigned{0, 128, 256, 256, 0, 256, 128, 0, 256}; - const std::string path = ::testing::TempDir() + "rabitq_ivf_sparse_add.index"; + const rabitq_test::TempFile path_file("rabitq_ivf_sparse_add.index"); + const std::string& path = path_file.path(); for (auto metric : {METRIC_L2, METRIC_IP}) { for (size_t bits : {1U, 4U, 32U}) { IVF index(kOld, kDynDim, kClusters, bits, metric); @@ -847,7 +848,6 @@ TEST(IvfAddTest, SparseInterleavedClustersPreserveRowOrderAndMatchRebuild) { EXPECT_EQ(saved_bytes(rebuilt, path), expected); } } - std::remove(path.c_str()); } TEST(IvfAddTest, UnreservedClustersGrowToExactlyTheirNewSize) { @@ -906,7 +906,8 @@ TEST(IvfReserveTest, RejectsBadInputAndLeavesSavedBytesUnchanged) { const auto points = random_rows(kOld, 871); const auto centroids = random_rows(kDynClusters, 872); const std::vector assigned(kOld, 0); - const std::string path = ::testing::TempDir() + "rabitq_ivf_reserve.index"; + const rabitq_test::TempFile path_file("rabitq_ivf_reserve.index"); + const std::string& path = path_file.path(); const auto expect_message = [](const auto& call, const char* message) { try { call(); @@ -954,7 +955,6 @@ TEST(IvfReserveTest, RejectsBadInputAndLeavesSavedBytesUnchanged) { for (size_t i = 0; i < kDynClusters; ++i) { EXPECT_FALSE(IvfTestAccess::has_spare_capacity(loaded, i)); } - std::remove(path.c_str()); } // Delegates to a real rotator, but the call numbered `fail_at` throws. @@ -993,8 +993,10 @@ TEST(IvfAddTest, FailedAddLeavesClustersGrowingInPlaceUnchanged) { std::fill(assigned.begin(), assigned.begin() + 40, PID{0}); const std::array new_clusters{0, 0, 0, 1, 1, 1}; const float* extra = points.data() + (kOld * kDynDim); - const std::string base_path = ::testing::TempDir() + "rabitq_ivf_add_fail_base.index"; - const std::string path = ::testing::TempDir() + "rabitq_ivf_add_fail.index"; + const rabitq_test::TempFile base_path_file("rabitq_ivf_add_fail_base.index"); + const std::string& base_path = base_path_file.path(); + const rabitq_test::TempFile path_file("rabitq_ivf_add_fail.index"); + const std::string& path = path_file.path(); for (size_t bits : {1UL, 4UL, 32UL}) { SCOPED_TRACE(::testing::Message() << "bits=" << bits); @@ -1025,8 +1027,6 @@ TEST(IvfAddTest, FailedAddLeavesClustersGrowingInPlaceUnchanged) { reference.add(extra, new_clusters.size(), new_clusters.data(), false, 1); EXPECT_TRUE(saved_bytes(index, path) == saved_bytes(reference, path)); } - std::remove(base_path.c_str()); - std::remove(path.c_str()); } TEST(IvfConstructionTest, RebuildMatchesIncrementalEncodingWithSkewedClustersAndTails) { @@ -1037,7 +1037,8 @@ TEST(IvfConstructionTest, RebuildMatchesIncrementalEncodingWithSkewedClustersAnd std::vector assigned(kCount, 3); assigned[0] = 1; std::fill(assigned.begin() + 1, assigned.begin() + 33, PID{2}); - const std::string path = ::testing::TempDir() + "rabitq_ivf_rebuild.index"; + const rabitq_test::TempFile path_file("rabitq_ivf_rebuild.index"); + const std::string& path = path_file.path(); for (auto metric : {METRIC_L2, METRIC_IP}) { for (auto rotation : {RotatorType::FhtKacRotator, RotatorType::MatrixRotator}) { for (size_t bits : {1UL, 2UL, 3UL, 4UL, 5UL, 6UL, 7UL, 8UL, 9UL, 32UL}) { @@ -1068,7 +1069,6 @@ TEST(IvfConstructionTest, RebuildMatchesIncrementalEncodingWithSkewedClustersAnd } } } - std::remove(path.c_str()); } TEST(IvfAddTest, KeepsExistingDistancesAndQuantizesNewPointsAccurately) { @@ -1159,8 +1159,10 @@ TEST(IvfAddTest, FasterQuantizationIsSupportedAndDeterministic) { const auto centroids = random_rows(kDynClusters, 42); const auto assigned = nearest_centroids(centroids, points.data(), kOld + kNew, METRIC_L2); - const std::string base_path = ::testing::TempDir() + "rabitq_ivf_add_faster_base.index"; - const std::string path = ::testing::TempDir() + "rabitq_ivf_add_faster.index"; + const rabitq_test::TempFile base_path_file("rabitq_ivf_add_faster_base.index"); + const std::string& base_path = base_path_file.path(); + const rabitq_test::TempFile path_file("rabitq_ivf_add_faster.index"); + const std::string& path = path_file.path(); IVF base(kOld, kDynDim, kDynClusters, 5); base.construct(points.data(), centroids.data(), assigned.data(), true, 1); @@ -1177,8 +1179,6 @@ TEST(IvfAddTest, FasterQuantizationIsSupportedAndDeterministic) { EXPECT_TRUE(std::none_of(hits.distances.begin(), hits.distances.end(), [](float x) { return std::isnan(x); })); - std::remove(base_path.c_str()); - std::remove(path.c_str()); } TEST(IvfAddTest, RoutesNewPointsWithHNSWCentroids) { @@ -1217,7 +1217,8 @@ TEST(IvfAddTest, RejectsBadInputAndLeavesTheIndexUnchanged) { const auto points = random_rows(kNum + 1, 51); const auto centroids = random_rows(kDynClusters, 52); const auto assigned = nearest_centroids(centroids, points.data(), kNum, METRIC_L2); - const std::string path = ::testing::TempDir() + "rabitq_ivf_add_reject.index"; + const rabitq_test::TempFile path_file("rabitq_ivf_add_reject.index"); + const std::string& path = path_file.path(); IVF unbuilt(kNum, kDynDim, kDynClusters, 4); EXPECT_THROW(unbuilt.add(points.data(), 1), std::logic_error); @@ -1239,7 +1240,6 @@ TEST(IvfAddTest, RejectsBadInputAndLeavesTheIndexUnchanged) { EXPECT_NO_THROW(index.add(points.data(), 0)); EXPECT_EQ(index.max_elements(), kNum); EXPECT_TRUE(saved_bytes(index, path) == expected); - std::remove(path.c_str()); } TEST(IvfRemoveTest, ExcludesRemovedPointsOnEveryScanPath) { @@ -1336,7 +1336,8 @@ TEST(IvfRemoveTest, SurvivesSaveLoadAndLaterAdds) { const auto centroids = random_rows(kDynClusters, 72); const auto queries = random_rows(kQueries, 73); const std::vector removed{1, 33, 34, 98, 99}; - const std::string path = ::testing::TempDir() + "rabitq_ivf_remove.index"; + const rabitq_test::TempFile path_file("rabitq_ivf_remove.index"); + const std::string& path = path_file.path(); // Counts the live results, checking that no removed point appears and that the unfilled // slots hold the sentinel with an infinite distance. @@ -1397,7 +1398,6 @@ TEST(IvfRemoveTest, SurvivesSaveLoadAndLaterAdds) { } } } - std::remove(path.c_str()); } TEST(IvfRemoveTest, RejectsBadInputAndRemovesNothingOnFailure) { @@ -1405,7 +1405,8 @@ TEST(IvfRemoveTest, RejectsBadInputAndRemovesNothingOnFailure) { const auto points = random_rows(kNum, 81); const auto centroids = random_rows(kDynClusters, 82); const auto assigned = nearest_centroids(centroids, points.data(), kNum, METRIC_L2); - const std::string path = ::testing::TempDir() + "rabitq_ivf_remove_reject.index"; + const rabitq_test::TempFile path_file("rabitq_ivf_remove_reject.index"); + const std::string& path = path_file.path(); const PID id = 0; IVF unbuilt(kNum, kDynDim, kDynClusters, 4); @@ -1431,7 +1432,6 @@ TEST(IvfRemoveTest, RejectsBadInputAndRemovesNothingOnFailure) { for (size_t r = 0; r < kNum; ++r) { EXPECT_EQ(hits.ids[r], kPidMax); } - std::remove(path.c_str()); } } // namespace @@ -1441,7 +1441,8 @@ TEST(IVFSidecarValidation, RejectsMismatchedVectorStorage) { using namespace rabitqlib; for (const auto metric : {METRIC_L2, METRIC_IP}) { for (const size_t sidecar_dim : {64U, 128U}) { - const std::string path = ::testing::TempDir() + "rabitq_sidecar_layout"; + const rabitq_test::TempFile path_file("rabitq_sidecar_layout"); + const std::string& path = path_file.path(); std::vector data(96, 1.0F), center(128, 0.0F); PID cluster = 0; ivf::IVF original( @@ -1467,8 +1468,6 @@ TEST(IVFSidecarValidation, RejectsMismatchedVectorStorage) { PID id = 0; float distance = 0; ASSERT_NO_THROW(loaded.search(data.data(), 1, 1, &id, &distance)); - std::filesystem::remove(path); - std::filesystem::remove(path + ".hnsw"); } } } diff --git a/tests/unit/rabitqlib/index/qg_test.cpp b/tests/unit/rabitqlib/index/qg_test.cpp index e20da63e..ddf682db 100644 --- a/tests/unit/rabitqlib/index/qg_test.cpp +++ b/tests/unit/rabitqlib/index/qg_test.cpp @@ -42,6 +42,7 @@ #include "rabitqlib/utils/cpu_features.hpp" #include "rabitqlib/utils/space.hpp" #include "rabitqlib/utils/tools.hpp" +#include "temp_file.hpp" namespace rabitqlib::symqg { struct QGConstructionTestAccess { @@ -1275,7 +1276,8 @@ TEST(QGConstructionTest, RefinesPartialSeedOnceAfterReleasingInputs) { EXPECT_LT(ids[j], kCount); EXPECT_TRUE(std::isfinite(distances[j])); } - const std::string path = ::testing::TempDir() + "rabitq_qg_seeded.index"; + const rabitq_test::TempFile path_file("rabitq_qg_seeded.index"); + const std::string& path = path_file.path(); graph.save(path.c_str()); QuantizedGraph loaded; loaded.load(path.c_str()); @@ -1284,7 +1286,6 @@ TEST(QGConstructionTest, RefinesPartialSeedOnceAfterReleasingInputs) { EXPECT_EQ(ids, loaded_ids); EXPECT_EQ(distances, loaded_distances); EXPECT_EQ(graph.entry_point(), loaded.entry_point()); - std::remove(path.c_str()); } } } @@ -1387,7 +1388,8 @@ TEST(QuantizedGraphPersistenceTest, RejectsMalformedPayloadWithoutChangingTarget QGBuilder builder(source, kDegree, data.data(), 1); builder.build(2); - const std::string path = ::testing::TempDir() + "rabitq_qg_malformed.index"; + const rabitq_test::TempFile path_file("rabitq_qg_malformed.index"); + const std::string& path = path_file.path(); QuantizedGraph target(65, kDim, kDegree, METRIC_IP); source.save(path.c_str()); @@ -1438,8 +1440,6 @@ TEST(QuantizedGraphPersistenceTest, RejectsMalformedPayloadWithoutChangingTarget EXPECT_THROW(target.load(path.c_str()), std::runtime_error); EXPECT_EQ(target.num_vertices(), 65U); EXPECT_EQ(target.metric_type(), METRIC_IP); - - std::remove(path.c_str()); } TEST(QuantizedGraphPersistenceTest, PreservesNewAndLegacyPaddedDomains) { @@ -1448,7 +1448,8 @@ TEST(QuantizedGraphPersistenceTest, PreservesNewAndLegacyPaddedDomains) { for (size_t i = 0; i < data.size(); ++i) { data[i] = std::sin(static_cast(i) * 0.13F); } - const std::string path = ::testing::TempDir() + "rabitq_qg_padding.index"; + const rabitq_test::TempFile path_file("rabitq_qg_padding.index"); + const std::string& path = path_file.path(); for (auto metric : {METRIC_L2, METRIC_IP}) { for (auto rotator : {RotatorType::FhtKacRotator, RotatorType::MatrixRotator}) { for (size_t bits : {0U, 4U, 8U}) { @@ -1492,7 +1493,6 @@ TEST(QuantizedGraphPersistenceTest, PreservesNewAndLegacyPaddedDomains) { } } } - std::remove(path.c_str()); } TEST(QuantizedGraphLifecycleTest, DestroysConcreteRotatorThroughBasePointer) { @@ -1501,8 +1501,8 @@ TEST(QuantizedGraphLifecycleTest, DestroysConcreteRotatorThroughBasePointer) { } TEST(QuantizedGraphLifecycleTest, RejectsSearchAndSaveBeforeBuild) { - const std::string path = ::testing::TempDir() + "rabitq_qg_unbuilt.index"; - std::remove(path.c_str()); + const rabitq_test::TempFile path_file("rabitq_qg_unbuilt.index"); + const std::string& path = path_file.path(); std::array query{}; std::array ids{}; std::array distances{}; @@ -1520,7 +1520,6 @@ TEST(QuantizedGraphLifecycleTest, RejectsSearchAndSaveBeforeBuild) { configured.search(query.data(), 1, ids.data(), distances.data()), std::logic_error ); EXPECT_FALSE(std::filesystem::exists(path)); - std::remove(path.c_str()); } TEST(QuantizedGraphLifecycleTest, BuilderDoesNotPublishPartialGraph) { @@ -1534,8 +1533,8 @@ TEST(QuantizedGraphLifecycleTest, BuilderDoesNotPublishPartialGraph) { for (const auto init : {QGInitialization::Random, QGInitialization::PiPNN}) { SCOPED_TRACE(static_cast(init)); - const std::string path = ::testing::TempDir() + "rabitq_qg_partial.index"; - std::remove(path.c_str()); + const rabitq_test::TempFile path_file("rabitq_qg_partial.index"); + const std::string& path = path_file.path(); QuantizedGraph graph(kNumPoints, kDim, kDegree); QGBuilder builder(graph, kDegree, data.data(), 1, init); graph.set_ef(1); @@ -1547,7 +1546,6 @@ TEST(QuantizedGraphLifecycleTest, BuilderDoesNotPublishPartialGraph) { EXPECT_NO_THROW(graph.search(query.data(), 1, &result, &distance)); EXPECT_NO_THROW(graph.save(path.c_str())); EXPECT_TRUE(std::filesystem::exists(path)); - std::remove(path.c_str()); } } @@ -1640,8 +1638,10 @@ TEST(QGQuantTest, SearchesAndRoundTripsFourAndEightBitIndexes) { EXPECT_TRUE(std::isfinite(distances[i])); } - const std::string path = - ::testing::TempDir() + "rabitq_qg_quant_" + std::to_string(bits) + ".index"; + const rabitq_test::TempFile path_file( + "rabitq_qg_quant_" + std::to_string(bits) + ".index" + ); + const std::string& path = path_file.path(); graph.save(path.c_str()); QuantizedGraph loaded; loaded.set_ef(kNumPoints); @@ -1656,7 +1656,6 @@ TEST(QGQuantTest, SearchesAndRoundTripsFourAndEightBitIndexes) { ); EXPECT_EQ(loaded_ids, ids); EXPECT_EQ(loaded_distances, distances); - std::remove(path.c_str()); } } diff --git a/tests/unit/rabitqlib/quantization/xy_quantization_test.cpp b/tests/unit/rabitqlib/quantization/xy_quantization_test.cpp index e68c20a7..77545f07 100644 --- a/tests/unit/rabitqlib/quantization/xy_quantization_test.cpp +++ b/tests/unit/rabitqlib/quantization/xy_quantization_test.cpp @@ -15,10 +15,8 @@ #include "rabitqlib/core/metric.hpp" #include "rabitqlib/core/query.hpp" #include "rabitqlib/utils/space.hpp" -#include "test_helpers.hpp" using namespace rabitqlib; -using namespace rabitq_test; namespace { @@ -164,12 +162,12 @@ TEST(XyQuantization, BaseBitsOneMatchesExistingOneBitPlusExBits) { EXPECT_EQ(res.extra_code[i], ref_ex_code[i]) << "dim " << i; } - EXPECT_FLOAT_NEARLY_EQUAL(res.f_add_base, ref_f_add_bin, 1e-4F); - EXPECT_FLOAT_NEARLY_EQUAL(res.f_rescale_base, ref_f_rescale_bin, 1e-4F); - EXPECT_FLOAT_NEARLY_EQUAL(res.f_error_base, ref_f_error_bin, 1e-4F); + EXPECT_NEAR(res.f_add_base, ref_f_add_bin, 1e-4F); + EXPECT_NEAR(res.f_rescale_base, ref_f_rescale_bin, 1e-4F); + EXPECT_NEAR(res.f_error_base, ref_f_error_bin, 1e-4F); - EXPECT_FLOAT_NEARLY_EQUAL(res.f_add_full, ref_f_add, 1e-4F); - EXPECT_FLOAT_NEARLY_EQUAL(res.f_rescale_full, ref_f_rescale, 1e-4F); + EXPECT_NEAR(res.f_add_full, ref_f_add, 1e-4F); + EXPECT_NEAR(res.f_rescale_full, ref_f_rescale, 1e-4F); // The combined code's f_error is not exposed by the function, so it is not // asserted here; ref_f_error stays as documentation of the reference value. } @@ -273,11 +271,11 @@ TEST(XyQuantization, BaseBitsOneMatchesExistingFactorsOnZeroResidualComponents) EXPECT_EQ(result.base_code[dim], reference_base_code[dim]); EXPECT_EQ(result.extra_code[dim], reference_extra_code[dim]); } - EXPECT_FLOAT_NEARLY_EQUAL(result.f_add_base, reference_base_add, 1e-4F); - EXPECT_FLOAT_NEARLY_EQUAL(result.f_rescale_base, reference_base_rescale, 1e-4F); - EXPECT_FLOAT_NEARLY_EQUAL(result.f_error_base, reference_base_error, 1e-4F); - EXPECT_FLOAT_NEARLY_EQUAL(result.f_add_full, reference_full_add, 1e-4F); - EXPECT_FLOAT_NEARLY_EQUAL(result.f_rescale_full, reference_full_rescale, 1e-4F); + EXPECT_NEAR(result.f_add_base, reference_base_add, 1e-4F); + EXPECT_NEAR(result.f_rescale_base, reference_base_rescale, 1e-4F); + EXPECT_NEAR(result.f_error_base, reference_base_error, 1e-4F); + EXPECT_NEAR(result.f_add_full, reference_full_add, 1e-4F); + EXPECT_NEAR(result.f_rescale_full, reference_full_rescale, 1e-4F); } } @@ -494,9 +492,9 @@ TEST(XyQuantization, EndToEndMatchesExistingFormulaAtBaseBitsOne) { kGError ); - EXPECT_FLOAT_NEARLY_EQUAL(ip_base, static_cast(ip_x0_qr), 1e-3F); - EXPECT_FLOAT_NEARLY_EQUAL(base_est, ref_base_est, 1e-2F); - EXPECT_FLOAT_NEARLY_EQUAL(base_low, base_est - (ref_f_error_bin * kGError), 1e-4F); + EXPECT_NEAR(ip_base, static_cast(ip_x0_qr), 1e-3F); + EXPECT_NEAR(base_est, ref_base_est, 1e-2F); + EXPECT_NEAR(base_low, base_est - (ref_f_error_bin * kGError), 1e-4F); // split_distance_boosting reads g_add off the query rather than taking // it as a parameter; there is no x+y-specific boosting function. @@ -505,7 +503,7 @@ TEST(XyQuantization, EndToEndMatchesExistingFormulaAtBaseBitsOne) { extra_data.data(), extra_ip_func, q_obj, kDim, kExtraBits, ip_base ); - EXPECT_FLOAT_NEARLY_EQUAL(boosted_est, ref_full_est, 1e-2F); + EXPECT_NEAR(boosted_est, ref_full_est, 1e-2F); // The one-shot path recomputes ip_base itself and must agree exactly with // the boosted path, plus supply the lower bound boosting omits. @@ -530,7 +528,7 @@ TEST(XyQuantization, EndToEndMatchesExistingFormulaAtBaseBitsOne) { EXPECT_FLOAT_EQ(full_ip_base, ip_base); EXPECT_FLOAT_EQ(full_est, boosted_est); - EXPECT_FLOAT_NEARLY_EQUAL( + EXPECT_NEAR( full_low, full_est - (ref_f_error_bin * kGError / static_cast(1 << kExtraBits)), 1e-4F @@ -634,7 +632,7 @@ TEST(XyQuantization, BoostedEstimateMatchesUnsplitCombinedCode) { ip_base ); - EXPECT_FLOAT_NEARLY_EQUAL(full_est, ref_est, 1e-2F); + EXPECT_NEAR(full_est, ref_est, 1e-2F); // The cheap filter's lower bound must not exclude the refined estimate, // otherwise the base layer would prune candidates the refine layer would // have kept. diff --git a/tests/unit/rabitqlib/utils/io_test.cpp b/tests/unit/rabitqlib/utils/io_test.cpp index 2f1b10ab..454524f4 100644 --- a/tests/unit/rabitqlib/utils/io_test.cpp +++ b/tests/unit/rabitqlib/utils/io_test.cpp @@ -12,6 +12,7 @@ #include "rabitqlib/core/detail/eigen.hpp" #include "rabitqlib/utils/path.hpp" +#include "temp_file.hpp" namespace rabitqlib { namespace { @@ -22,7 +23,8 @@ void write_value(std::ofstream& output, const T& value) { } TEST(VectorIoTest, LoadsCompleteConsistentRecords) { - const std::string path = ::testing::TempDir() + "rabitq_valid.fvecs"; + const rabitq_test::TempFile path_file("rabitq_valid.fvecs"); + const std::string& path = path_file.path(); { std::ofstream output(path, std::ios::binary); const uint32_t cols = 2; @@ -39,7 +41,6 @@ TEST(VectorIoTest, LoadsCompleteConsistentRecords) { ASSERT_EQ(matrix.cols(), 2); EXPECT_FLOAT_EQ(matrix(0, 0), 1.0F); EXPECT_FLOAT_EQ(matrix(1, 1), 4.0F); - std::remove(path.c_str()); } TEST(BinaryMatrixIoTest, LoadsUtf8PathsAndChecksTheirSize) { @@ -91,7 +92,8 @@ TEST(BinaryMatrixIoTest, LoadsNonUtf8NativePaths) { #endif TEST(VectorIoTest, RejectsInconsistentAndTruncatedRecordsWithoutReplacingOutput) { - const std::string path = ::testing::TempDir() + "rabitq_invalid.fvecs"; + const rabitq_test::TempFile path_file("rabitq_invalid.fvecs"); + const std::string& path = path_file.path(); RowMajorMatrix matrix(1, 1); matrix(0, 0) = 7.0F; @@ -119,11 +121,11 @@ TEST(VectorIoTest, RejectsInconsistentAndTruncatedRecordsWithoutReplacingOutput) EXPECT_THROW(load_vecs(path.c_str(), matrix), std::runtime_error); ASSERT_EQ(matrix.rows(), 1); EXPECT_FLOAT_EQ(matrix(0, 0), 7.0F); - std::remove(path.c_str()); } TEST(BinaryMatrixIoTest, ValidatesPayloadBeforeReplacingOutput) { - const std::string path = ::testing::TempDir() + "rabitq_matrix.bin"; + const rabitq_test::TempFile path_file("rabitq_matrix.bin"); + const std::string& path = path_file.path(); RowMajorMatrix matrix(1, 1); matrix(0, 0) = 7.0F; @@ -153,7 +155,6 @@ TEST(BinaryMatrixIoTest, ValidatesPayloadBeforeReplacingOutput) { ASSERT_EQ(matrix.rows(), 2); ASSERT_EQ(matrix.cols(), 2); EXPECT_FLOAT_EQ(matrix(1, 1), 4.0F); - std::remove(path.c_str()); } } // namespace diff --git a/tests/unit/rabitqlib/utils/rotator_test.cpp b/tests/unit/rabitqlib/utils/rotator_test.cpp index 110d1fe2..2d0f3262 100644 --- a/tests/unit/rabitqlib/utils/rotator_test.cpp +++ b/tests/unit/rabitqlib/utils/rotator_test.cpp @@ -9,16 +9,15 @@ #include #include #include +#include #include #include #include "rabitqlib/core/detail/simd/rotator_dispatch.hpp" #include "rabitqlib/utils/cpu_features.hpp" #include "rabitqlib/utils/memory.hpp" -#include "test_data.hpp" using namespace rabitqlib; -using namespace rabitq_test; class RotatorTest : public ::testing::Test { protected: @@ -82,7 +81,10 @@ TEST(FhtRotatorTest, PadsToThirtyTwoAndAcceptsLegacyPadding) { choose_rotator(dim, RotatorType::FhtKacRotator, legacy_padded, 43) ); loaded->load(state.data()); - auto input = TestDataGenerator::GenerateRandomVector(dim, -1.0f, 1.0f, 42); + std::mt19937 generator(42); + std::uniform_real_distribution uniform(-1.0F, 1.0F); + std::vector input(dim); + std::generate(input.begin(), input.end(), [&] { return uniform(generator); }); std::vector expected(legacy_padded), actual(legacy_padded); legacy->rotate(input.data(), expected.data()); loaded->rotate(input.data(), actual.data()); From 11d0aaa560a55f4bbcf685aee044fad4c2737953 Mon Sep 17 00:00:00 2001 From: gouyt13clear Date: Sat, 31 Oct 2026 04:44:32 +0800 Subject: [PATCH 2/3] ci: cache compilations and drop duplicate builds - Persist ccache between runs with actions/cache in every Linux and macOS job that builds with native tuning disabled. The native-tuned Ubuntu job stays uncached because ccache keys -march=native literally. - Build the Linux Python extension from source once (3.11); release wheel jobs already build and fully test cp311 and cp314 on every PR. - Build each Windows wheel ABI on its own runner, the longest PR check. - Run the add_subdirectory and FetchContent consumer integrations on the GCC Ubuntu partition and one MSVC partition; run.cmake gains RABITQ_INTEGRATIONS=ALL|PACKAGE and other partitions check the package. - Add a cpp_core selection so index- and clustering-only changes skip the core/FastScan consumer matrix. - Use a blob-less clone and the runner Python for change detection, and the runner's ShellCheck. - Parse workflows with PyYAML in the CI contract tests instead of matching their text, and cover the new cache, integration and selection rules. --- .github/actions/setup-ccache/action.yml | 41 ++ .github/scripts/ci_changes.py | 40 +- .github/scripts/test_ci_aggregates.py | 36 +- .github/scripts/test_ci_changes.py | 94 +++- .github/scripts/test_ci_workflow_contracts.py | 404 ++++++++++++------ .github/scripts/workflow_data.py | 51 +++ .github/workflows/changes.yml | 11 +- .github/workflows/python.yml | 8 +- .github/workflows/release.yml | 15 +- .github/workflows/test.yaml | 68 +-- docs/maintaining.md | 42 +- tests/README.md | 14 +- tests/core_consumer/run.cmake | 14 +- 13 files changed, 621 insertions(+), 217 deletions(-) create mode 100644 .github/actions/setup-ccache/action.yml create mode 100644 .github/scripts/workflow_data.py diff --git a/.github/actions/setup-ccache/action.yml b/.github/actions/setup-ccache/action.yml new file mode 100644 index 00000000..8c5417b4 --- /dev/null +++ b/.github/actions/setup-ccache/action.yml @@ -0,0 +1,41 @@ +name: Set up ccache +description: >- + Install ccache, route CMake compilations through it and keep its cache between runs + on Linux and macOS. Visual Studio generators do not support compiler launchers. +inputs: + key: + description: Cache partition for one compiler and build configuration. + required: true +runs: + using: composite + steps: + - name: Install ccache + shell: bash + run: | + set -euo pipefail + if ! command -v ccache >/dev/null 2>&1; then + if [[ "$RUNNER_OS" == Linux ]]; then + sudo apt-get update -qq + sudo apt-get install -y ccache + else + brew install ccache + fi + fi + ccache --version | head -n 1 + { + echo "CCACHE_DIR=$RUNNER_TEMP/ccache" + echo "CCACHE_BASEDIR=$GITHUB_WORKSPACE" + echo "CCACHE_MAXSIZE=500M" + echo "CMAKE_C_COMPILER_LAUNCHER=ccache" + echo "CMAKE_CXX_COMPILER_LAUNCHER=ccache" + } >> "$GITHUB_ENV" + # Each run saves under its commit; later runs restore the newest matching partition. + - name: Restore compiler cache + uses: actions/cache@v6 + with: + path: ${{ runner.temp }}/ccache + key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ inputs.key }}-${{ github.sha }} + restore-keys: ccache-${{ runner.os }}-${{ runner.arch }}-${{ inputs.key }}- + - name: Reset compiler cache statistics + shell: bash + run: ccache --zero-stats diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 44837891..4c054a15 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -8,6 +8,7 @@ GROUPS = ( "cpp", + "cpp_core", "cpp_format", "cpp_tidy", "python_quality", @@ -20,6 +21,34 @@ CPP_SUFFIXES = (".c", ".cc", ".cpp", ".h", ".hpp") +# Core and FastScan consumer builds exclude ANN indexes, clustering and their tests. +# Anything not listed here keeps those consumer checks selected. +INDEX_ONLY_PREFIXES = ( + "include/rabitqlib/clustering/", + "include/rabitqlib/index/", + "include/rabitqlib/third/hnswlib/", + "src/index/", + "tests/consumer/", + "tests/integration/", + "tests/unit/rabitqlib/clustering/", + "tests/unit/rabitqlib/index/", +) +CORE_SAMPLE_PREFIXES = ("sample/cpp/core/", "sample/cpp/fastscan/") +INDEX_ONLY_TESTS = frozenset( + ( + "tests/failures/construction_failure_test.cpp", + "tests/failures/hnsw_add_allocation_test.cpp", + ) +) + + +def selects_core(path): + """Whether a C++ change can affect the core and FastScan consumer builds.""" + if path.startswith(INDEX_ONLY_PREFIXES) or path in INDEX_ONLY_TESTS: + return False + return not path.startswith("sample/cpp/") or path.startswith(CORE_SAMPLE_PREFIXES) + + BENCHMARK_PYTHON_PATHS = frozenset( f"benchmarks/{name}.py" for name in ( @@ -107,11 +136,13 @@ def classify(paths): selected.update(("python_quality", "python_build", "wheels")) elif path.startswith("tests/fixtures/core_api_v1/"): # Frozen compatibility fixture; excluded from clang-format by design. - selected.add("cpp") + selected.update(("cpp", "cpp_core")) elif path == "pyproject.toml": selected.update(("python_quality", "python_build", "wheels", "benchmarks")) elif path == "CMakeLists.txt" or path.startswith("cmake/"): - selected.update(("cpp", "cpp_tidy", "python_build", "wheels", "benchmarks")) + selected.update( + ("cpp", "cpp_core", "cpp_tidy", "python_build", "wheels", "benchmarks") + ) elif path.startswith(("include/", "src/")): selected.update( ( @@ -123,6 +154,8 @@ def classify(paths): "benchmarks", ) ) + if selects_core(path): + selected.add("cpp_core") elif path.startswith("python_bindings/"): selected.update(("python_build", "wheels", "benchmarks")) if path.endswith((".py", ".pyi")): @@ -146,6 +179,8 @@ def classify(paths): or path == "tests/CMakeLists.txt" ): selected.add("cpp") + if selects_core(path): + selected.add("cpp_core") if path.endswith(CPP_SUFFIXES): selected.add("cpp_format") elif path == "tests/python/test_cpp_clustering_examples.py": @@ -175,6 +210,7 @@ def classify(paths): selected.update( ( "cpp", + "cpp_core", "cpp_format", "cpp_tidy", "shell", diff --git a/.github/scripts/test_ci_aggregates.py b/.github/scripts/test_ci_aggregates.py index 2d7375fd..1868d816 100644 --- a/.github/scripts/test_ci_aggregates.py +++ b/.github/scripts/test_ci_aggregates.py @@ -1,24 +1,21 @@ """Execute the matrix verdict commands with success, skip and failure inputs.""" import os -import re import subprocess -import textwrap import unittest -from pathlib import Path -WORKFLOWS = Path(__file__).resolve().parents[1] / "workflows" +from workflow_data import job, load_workflow, needs, steps class MatrixSummaryTest(unittest.TestCase): def test_matrix_verdicts_and_dependencies(self): - for filename, job, dependency, output, selection_var in ( + for filename, job_name, dependency, output, selection_var in ( ("test.yaml", "clang-result", "clang", "cpp", "CPP_SELECTED"), ( "test.yaml", "core-consumer-result", "core-consumer", - "cpp", + "cpp_core", "CPP_SELECTED", ), ( @@ -30,20 +27,19 @@ def test_matrix_verdicts_and_dependencies(self): ), ("docs.yml", "docs-result", "build", "docs", "DOCS_SELECTED"), ): - workflow = (WORKFLOWS / filename).read_text() - block = re.search( - rf"^ {job}:\n(.*?)(?=^ [\w-]+:|\Z)", - workflow, - re.MULTILINE | re.DOTALL, - ).group(1) - self.assertIn(f"needs: [changes, {dependency}]", block) - self.assertIn("if: ${{ always() }}", block) - self.assertIn(f"MATRIX_RESULT: ${{{{ needs.{dependency}.result }}}}", block) - self.assertIn("CHANGES_RESULT: ${{ needs.changes.result }}", block) - self.assertIn( - f"{selection_var}: ${{{{ needs.changes.outputs.{output} }}}}", block + summary = job(load_workflow(filename), job_name) + self.assertEqual(needs(summary), ["changes", dependency]) + self.assertEqual(summary["if"], "${{ always() }}") + (verdict,) = steps(summary) + self.assertEqual( + verdict["env"], + { + "CHANGES_RESULT": "${{ needs.changes.result }}", + selection_var: f"${{{{ needs.changes.outputs.{output} }}}}", + "MATRIX_RESULT": f"${{{{ needs.{dependency}.result }}}}", + }, ) - command = textwrap.dedent(block.split(" run: |\n", 1)[1]) + command = verdict["run"] for detection in ("success", "failure", "cancelled", "skipped"): for selected in ("true", "false", ""): for matrix in ("success", "failure", "cancelled", "skipped", ""): @@ -53,7 +49,7 @@ def test_matrix_verdicts_and_dependencies(self): and matrix == "skipped" ) with self.subTest( - job=job, + job=job_name, detection=detection, selected=selected, matrix=matrix, diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index 27764570..df2008aa 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -12,6 +12,7 @@ def test_benchmark_and_workflow_self_coverage(self): cases = { ".github/workflows/test.yaml": { "cpp", + "cpp_core", "cpp_format", "cpp_tidy", "shell", @@ -73,8 +74,8 @@ def test_known_support_paths_do_not_select_unrelated_builds(self): "example_py.sh": {"shell"}, "python/ivf.py": {"python_quality", "python_build", "wheels"}, "python/utils/io.py": {"python_quality", "python_build", "wheels"}, - "tests/core_consumer/run.cmake": {"cpp", "python_quality"}, - "tests/fixtures/core_api_v1/consumer.cpp": {"cpp"}, + "tests/core_consumer/run.cmake": {"cpp", "cpp_core", "python_quality"}, + "tests/fixtures/core_api_v1/consumer.cpp": {"cpp", "cpp_core"}, ".github/scripts/repair_windows_wheel.py": {"python_quality", "wheels"}, ".github/scripts/test_repair_windows_wheel.py": { "python_quality", @@ -92,6 +93,7 @@ def test_support_paths_do_not_mask_source_or_unknown_changes(self): { "benchmarks", "cpp", + "cpp_core", "cpp_format", "cpp_tidy", "python_build", @@ -103,25 +105,70 @@ def test_support_paths_do_not_mask_source_or_unknown_changes(self): self.assertEqual(classify(["benchmarks/new_kernel.cpp"]), set(GROUPS)) def test_shared_code_runs_cpp_and_python(self): - for path in ("include/rabitqlib/index/ivf/ivf.hpp", "src/simd/dispatch.cpp"): + library = { + "cpp", + "cpp_format", + "cpp_tidy", + "python_build", + "wheels", + "benchmarks", + } + for path, core in ( + ("include/rabitqlib/index/ivf/ivf.hpp", False), + ("src/simd/dispatch.cpp", True), + ): with self.subTest(path=path): self.assertEqual( - classify([path]), - { - "cpp", - "cpp_format", - "cpp_tidy", - "python_build", - "wheels", - "benchmarks", - }, + classify([path]), library | ({"cpp_core"} if core else set()) ) + def test_index_only_cpp_changes_skip_core_consumers(self): + for path in ( + "src/index/ivf.cpp", + "include/rabitqlib/index/hnsw/hnsw.hpp", + "include/rabitqlib/clustering/qgkmeans.hpp", + "include/rabitqlib/third/hnswlib/hnswalg.h", + "tests/unit/rabitqlib/index/qg_test.cpp", + "tests/unit/rabitqlib/clustering/qgkmeans_test.cpp", + "tests/failures/construction_failure_test.cpp", + "tests/failures/hnsw_add_allocation_test.cpp", + "tests/consumer/main.cpp", + "tests/integration/cpp_example_index_check.cpp", + "sample/cpp/ivf_rabitq_querying.cpp", + ): + with self.subTest(path=path): + selected = classify([path]) + self.assertIn("cpp", selected) + self.assertNotIn("cpp_core", selected) + for path in ( + "src/core/api.cpp", + "src/fastscan/fastscan_avx2.cpp", + "include/rabitqlib/core/api.hpp", + "include/rabitqlib/utils/space.hpp", + "include/rabitqlib/third/Eigen/Core", + "tests/unit/rabitqlib/quantization/core_api_test.cpp", + "tests/unit/rabitqlib/utils/thread_count_test.cpp", + "tests/common/temp_file.hpp", + "tests/failures/compact_codes_failure_test.cpp", + "tests/CMakeLists.txt", + "tests/core_consumer/CMakeLists.txt", + "sample/cpp/core/main.cpp", + "sample/cpp/fastscan/main.cpp", + "CMakeLists.txt", + ): + with self.subTest(path=path): + self.assertLessEqual({"cpp", "cpp_core"}, classify([path])) + self.assertIn("cpp_core", classify(["src/index/ivf.cpp", "src/core/api.cpp"])) + def test_language_specific_changes(self): cases = { "tests/unit/rabitqlib/index/ivf_test.cpp": {"cpp", "cpp_format"}, - "tests/dispatch/fastscan_no_simd_test.cpp": {"cpp", "cpp_format"}, - "tests/allocation/core_api_no_alloc.cpp": {"cpp", "cpp_format"}, + "tests/dispatch/fastscan_no_simd_test.cpp": { + "cpp", + "cpp_core", + "cpp_format", + }, + "tests/allocation/core_api_no_alloc.cpp": {"cpp", "cpp_core", "cpp_format"}, "tests/failures/construction_failure_test.cpp": {"cpp", "cpp_format"}, "sample/cpp/ivf_rabitq_querying.cpp": {"cpp", "cpp_format"}, "python_bindings/ivf_bindings.cpp": { @@ -179,6 +226,7 @@ def test_language_specific_changes(self): ".clang-tidy": {"cpp_tidy"}, ".github/workflows/test.yaml": { "cpp", + "cpp_core", "cpp_format", "cpp_tidy", "python_quality", @@ -199,14 +247,28 @@ def test_build_configuration_selects_affected_checks(self): with self.subTest(path=path): self.assertEqual( classify([path]), - {"cpp", "cpp_tidy", "python_build", "wheels", "benchmarks"}, + { + "cpp", + "cpp_core", + "cpp_tidy", + "python_build", + "wheels", + "benchmarks", + }, ) def test_mixed_changes_and_renamed_source(self): self.assertIn("cpp", classify(["docs/new.md", "src/deleted.cpp"])) self.assertEqual( classify(["sample/python/tool.py", "tests/unit/example.cpp"]), - {"cpp", "cpp_format", "python_quality", "python_build", "wheels"}, + { + "cpp", + "cpp_core", + "cpp_format", + "python_quality", + "python_build", + "wheels", + }, ) def test_unknown_inputs_and_filter_edits_run_everything(self): diff --git a/.github/scripts/test_ci_workflow_contracts.py b/.github/scripts/test_ci_workflow_contracts.py index bb1036dc..0df7efc4 100644 --- a/.github/scripts/test_ci_workflow_contracts.py +++ b/.github/scripts/test_ci_workflow_contracts.py @@ -11,15 +11,20 @@ import unittest from pathlib import Path +import yaml from ci_changes import GROUPS, classify - -ROOT = Path(__file__).resolve().parents[2] +from workflow_data import ROOT, job, load_workflow, needs, runs, step, steps, uses class WorkflowContractsTest(unittest.TestCase): def test_ctest_invocations_reject_empty_selections(self): - workflow = (ROOT / ".github/workflows/test.yaml").read_text() - commands = re.findall(r"\bctest\s+[^\n]+", workflow) + workflow = load_workflow("test.yaml") + commands = [ + command + for job_data in workflow["jobs"].values() + for run in runs(job_data) + for command in re.findall(r"\bctest\s+[^\n]+", run) + ] self.assertTrue(commands) for command in commands: with self.subTest(command=command): @@ -38,7 +43,7 @@ def test_ctest_invocations_reject_empty_selections(self): self.assertIn("No tests were found", result.stdout + result.stderr) def test_wheels_preserve_abi_coverage_and_release_gates(self): - workflow = (ROOT / ".github/workflows/release.yml").read_text() + workflow = load_workflow("release.yml") config = tomllib.loads((ROOT / "pyproject.toml").read_text()) wheels = config["tool"]["cibuildwheel"] # Keep the full installed-wheel suite and platform repair hooks. @@ -49,7 +54,40 @@ def test_wheels_preserve_abi_coverage_and_release_gates(self): wheels["windows"]["repair-wheel-command"], ) self.assertIn("scripts/build-macos-openmp.sh", wheels["macos"]["before-all"]) - self.assertEqual(workflow.count("pattern: '*-wheels'"), 2) + patterns = [ + entry["with"]["pattern"] + for job_data in workflow["jobs"].values() + for entry in uses(job_data, "actions/download-artifact") + ] + self.assertEqual(patterns, ["*-wheels*", "*-wheels*"]) + pr_selection = ( + r"github\.event_name == 'pull_request' && '([^']+)' \|\| '([^']+)'" + ) + + def selections(build, event): + """Return the cibuildwheel identifier patterns and artifact names.""" + cibw = next( + entry + for entry in steps(build) + if "pypa/cibuildwheel" in entry.get("uses", "") + )["env"]["CIBW_BUILD"] + artifact = uses(build, "actions/upload-artifact")[0]["with"]["name"] + matrix = build.get("strategy", {}).get("matrix") + if matrix is None: + pr, release = re.search(pr_selection, cibw).groups() + return [(pr if event == "pull_request" else release, artifact)] + # One runner per ABI: the matrix itself carries the event-specific list. + self.assertFalse(build["strategy"]["fail-fast"]) + pr, release = re.search(pr_selection, matrix["abi"]).groups() + values = json.loads(pr if event == "pull_request" else release) + return [ + ( + cibw.replace("${{ matrix.abi }}", abi), + artifact.replace("${{ matrix.abi }}", abi), + ) + for abi in values + ] + artifacts = set() for prefix, tag, check_name in ( ("linux", "manylinux_x86_64", "Linux x86-64 wheels"), @@ -62,53 +100,47 @@ def test_wheels_preserve_abi_coverage_and_release_gates(self): ("macos", "macosx_arm64", "macOS arm64 wheels (CPython 3.11–3.14)"), ): with self.subTest(platform=prefix): - build = workflow.split(f" {prefix}-wheel-build:\n", 1)[1].split( - f" {prefix}-wheels:\n", 1 - )[0] - selection = re.search( - r"CIBW_BUILD: \$\{\{ github.event_name == 'pull_request' " - r"&& '([^']+)' \|\| '([^']+)' \}\}", - build, - ) - self.assertIsNotNone(selection) + build = job(workflow, f"{prefix}-wheel-build") supported = { identifier for identifier in wheels["build"].split() if identifier.endswith(f"-{tag}") } - for patterns, abis in zip( - selection.groups(), ((311, 314), (311, 312, 313, 314)), strict=True + for event, abis in ( + ("pull_request", (311, 314)), + ("push", (311, 312, 313, 314)), ): - selected = { - identifier - for identifier in supported - if any( - fnmatch.fnmatchcase(identifier, p) for p in patterns.split() - ) - } + selected = set() + for patterns_text, artifact in selections(build, event): + selected |= { + identifier + for identifier in supported + if any( + fnmatch.fnmatchcase(identifier, pattern) + for pattern in patterns_text.split() + ) + } + self.assertTrue(fnmatch.fnmatchcase(artifact, "*-wheels*")) + if event == "push": + self.assertNotIn(artifact, artifacts) + artifacts.add(artifact) self.assertEqual(selected, {f"cp{abi}-{tag}" for abi in abis}) - self.assertNotIn("CIBW_TEST", build) self.assertNotIn("continue-on-error", build) - self.assertNotIn("matrix", build) - artifact = re.search(r" name: (\S+)", build).group(1) - self.assertTrue(fnmatch.fnmatchcase(artifact, "*-wheels")) - self.assertNotIn(artifact, artifacts) - artifacts.add(artifact) - gate = re.search( - rf"^ {prefix}-wheels:\n(.*?)(?=^ [\w-]+:|\Z)", - workflow, - re.MULTILINE | re.DOTALL, - ).group(1) - self.assertIn(f"name: {check_name}", gate) - self.assertIn(f"needs: {prefix}-wheel-build", gate) - self.assertIn( - f"BUILD_RESULT: ${{{{ needs.{prefix}-wheel-build.result }}}}", gate + for entry in steps(build): + self.assertNotIn("CIBW_TEST", " ".join(entry.get("env", {}))) + 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"]) + (verdict_step,) = steps(gate) + self.assertEqual( + verdict_step["env"]["BUILD_RESULT"], + f"${{{{ needs.{prefix}-wheel-build.result }}}}", ) - command = gate.split(" run: ", 1)[1].strip() for result in ("success", "failure", "cancelled", "skipped", ""): with self.subTest(result=result): verdict = subprocess.run( - ["bash", "-e", "-c", command], + ["bash", "-e", "-c", verdict_step["run"]], env=dict(os.environ, BUILD_RESULT=result), capture_output=True, check=False, @@ -146,14 +178,15 @@ def test_installed_typing_checks_are_required(self): self.assertFalse( any(value.startswith("mypy") for value in config["project"]["dependencies"]) ) - workflow = (ROOT / ".github/workflows/python.yml").read_text() - build = workflow.split(" python-build:\n", 1)[1].split( - " python-result:\n", 1 - )[0] - self.assertIn('pip install ".[test,typing]"', build) - self.assertIn( - "python -m pytest tests/python/test_editor_support.py::test_installed_public_declarations", - build, + build = job(load_workflow("python.yml"), "python-build") + commands = runs(build) + self.assertTrue(any('pip install ".[test,typing]"' in run for run in commands)) + self.assertTrue( + any( + "python -m pytest tests/python/test_editor_support.py" + "::test_installed_public_declarations" in run + for run in commands + ) ) self.assertNotIn("continue-on-error", build) self.assertIn( @@ -182,27 +215,28 @@ def test_nonrelease_push_keeps_typing_checks_when_wheels_skip(self): release = ReleaseTest().run_prepare(tags="v0.3.3", tag_sha="b" * 40) self.assertEqual(release["build"], "false") - workflow = (ROOT / ".github/workflows/python.yml").read_text() - build = workflow.split(" python-build:\n", 1)[1].split( - " python-result:\n", 1 - )[0] - steps = re.findall( - r"^ - name: (.*?)(?=^ - |\Z)", - build, - re.MULTILINE | re.DOTALL, - ) - typing_steps = [step for step in steps if "tests/test_mypy.py" in step] + build = job(load_workflow("python.yml"), "python-build") + typing_steps = [ + entry + for entry in steps(build) + if "tests/test_mypy.py" in entry.get("run", "") + ] self.assertEqual(len(typing_steps), 1) - self.assertIn("if: github.event_name == 'push'", typing_steps[0]) - self.assertIn( - "run: python -m pytest tests/python/test_editor_support.py tests/test_mypy.py", - typing_steps[0], + self.assertEqual(typing_steps[0]["if"], "github.event_name == 'push'") + self.assertTrue( + typing_steps[0]["run"].startswith( + "python -m pytest tests/python/test_editor_support.py tests/test_mypy.py" + ) ) declaration_steps = [ - step for step in steps if "::test_installed_public_declarations" in step + entry + for entry in steps(build) + if "::test_installed_public_declarations" in entry.get("run", "") ] self.assertEqual(len(declaration_steps), 1) - self.assertIn("if: github.event_name == 'pull_request'", declaration_steps[0]) + self.assertEqual( + declaration_steps[0]["if"], "github.event_name == 'pull_request'" + ) for path in ( "tests/test_mypy.py", "tests/python/test_editor_support.py", @@ -240,14 +274,15 @@ def test_pyright_checks_are_required_and_scoped(self): for dependency in config["tool"]["cibuildwheel"]["test-requires"] ) ) - workflow = (ROOT / ".github/workflows/python.yml").read_text() - build = workflow.split(" python-build:\n", 1)[1].split( - " python-result:\n", 1 - )[0] - self.assertIn("actions/setup-node@v7", build) - self.assertIn('node-version: "24"', build) - self.assertIn('pip install ".[test,typing]"', build) - self.assertIn("python -m pytest tests/test_pyright.py", build) + build = job(load_workflow("python.yml"), "python-build") + (node,) = uses(build, "actions/setup-node") + self.assertEqual(node["uses"], "actions/setup-node@v7") + self.assertEqual(node["with"]["node-version"], "24") + commands = runs(build) + self.assertTrue(any('pip install ".[test,typing]"' in run for run in commands)) + self.assertTrue( + any("python -m pytest tests/test_pyright.py" in run for run in commands) + ) self.assertNotIn("continue-on-error", build) self.assertEqual( classify(["tests/test_pyright.py"]), {"python_quality", "python_build"} @@ -388,14 +423,11 @@ def test_benchmark_support_changes_keep_protocol_coverage_without_builds(self): "cpp", classify(["benchmarks/build_indexes.py", "src/core/api.cpp"]), ) - workflow = (ROOT / ".github/workflows/python.yml").read_text() - quality = workflow.split(" python-quality:\n", 1)[1].split( - " python-build:\n", 1 - )[0] - self.assertIn("tests/python/test_benchmark_protocol.py", quality) - self.assertIn("numpy", quality) - self.assertIn("pytest", quality) - self.assertNotIn("pip install .", quality) + commands = "\n".join(runs(job(load_workflow("python.yml"), "python-quality"))) + self.assertIn("tests/python/test_benchmark_protocol.py", commands) + self.assertIn("numpy", commands) + self.assertIn("pytest", commands) + self.assertNotIn("pip install .", commands) def test_python_checker_covers_every_benchmark_python_file(self): with tempfile.TemporaryDirectory() as directory: @@ -451,8 +483,7 @@ def expand(match): return re.sub(r"\$\{\{(.*?)\}\}", expand, template) for filename in ("test.yaml", "python.yml"): - text = (ROOT / ".github/workflows" / filename).read_text() - template = re.search(r"^ group: (.+)$", text, re.MULTILINE).group(1) + template = load_workflow(filename)["concurrency"]["group"] with self.subTest(workflow=filename): self.assertNotEqual( group(template, 201, "a", filename), @@ -468,15 +499,12 @@ def expand(match): ) def test_windows_consumers_cover_each_component_and_openmp_mode(self): - workflow = (ROOT / ".github/workflows/test.yaml").read_text() - block = workflow.split(" core-consumer:\n", 1)[1].split( - " core-consumer-result:\n", 1 - )[0] - variants = re.findall( - r'- os: windows-2025-vs2026\n\s+openmp: "(ON|OFF)"' - r"\n\s+component: (core|fastscan)", - block, - ) + consumers = job(load_workflow("test.yaml"), "core-consumer") + variants = [ + (entry["openmp"], entry["component"]) + for entry in consumers["strategy"]["matrix"]["include"] + if entry["os"] == "windows-2025-vs2026" + ] self.assertCountEqual( variants, [ @@ -485,14 +513,84 @@ def test_windows_consumers_cover_each_component_and_openmp_mode(self): for component in ("core", "fastscan") ], ) - windows = block.split( - " - name: Test external core-only integrations (Windows)\n", 1 - )[1].split(" - name: Report compiler cache statistics", 1)[0] - self.assertIn("-DRABITQ_COMPONENT=${{ matrix.component }}", windows) + windows = step(consumers, "Test external core-only integrations (Windows)") + self.assertIn("-DRABITQ_COMPONENT=${{ matrix.component }}", windows["run"]) self.assertIn( "-DRABITQ_RUN_REFERENCE_TESTS=${{ matrix.openmp == 'OFF' && " "matrix.component != 'core' && 'ON' || 'OFF' }}", - windows, + windows["run"], + ) + + def test_source_integrations_keep_one_gcc_and_one_msvc_owner(self): + workflow = load_workflow("test.yaml") + consumers = job(workflow, "core-consumer") + selection = ( + "-DRABITQ_INTEGRATIONS=${{ (matrix.os == 'ubuntu-latest' || " + "matrix.integrations == 'ALL') && 'ALL' || 'PACKAGE' }}" + ) + for platform in ("Linux", "macOS", "Windows"): + with self.subTest(platform=platform): + entry = step( + consumers, f"Test external core-only integrations ({platform})" + ) + self.assertIn(selection, entry["run"]) + matrix = consumers["strategy"]["matrix"] + owners = [ + entry for entry in matrix["include"] if entry.get("integrations") == "ALL" + ] + self.assertEqual( + [(e["os"], e["openmp"], e["component"]) for e in owners], + [("windows-2025-vs2026", "ON", "core")], + ) + self.assertIn("ubuntu-latest", matrix["os"]) + self.assertEqual(matrix["openmp"], ["ON", "OFF"]) + clang = step(job(workflow, "clang"), "Test core and FastScan integrations") + self.assertIn("-DRABITQ_INTEGRATIONS=PACKAGE", clang["run"]) + + def test_compiling_unix_jobs_restore_a_compiler_cache(self): + workflow = load_workflow("test.yaml") + action = yaml.safe_load( + (ROOT / ".github/actions/setup-ccache/action.yml").read_text() + ) + (cache,) = [entry for entry in action["runs"]["steps"] if "uses" in entry] + self.assertTrue(cache["uses"].startswith("actions/cache@")) + self.assertIn("${{ github.sha }}", cache["with"]["key"]) + self.assertTrue(cache["with"]["key"].startswith(cache["with"]["restore-keys"])) + keys = [] + for name, job_data in workflow["jobs"].items(): + commands = runs(job_data) + compiles = any( + "cmake --build" in run or "tests/core_consumer/run.cmake" in run + for run in commands + ) + if not compiles or job_data["runs-on"].startswith("windows"): + continue + setups = uses(job_data, "./.github/actions/setup-ccache") + # run.cmake disables native tuning; direct source configurations must say so. + portable = all( + "-DRABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF" in run + for run in commands + if re.search(r"cmake\s+-S\s+\.\s", run) + ) + with self.subTest(job=name): + if not portable: + # ccache keys -march=native literally; another CPU could reuse it. + self.assertEqual(setups, []) + continue + self.assertEqual(len(setups), 1, "a compiling job must set up ccache") + setup = setups[0] + position = steps(job_data).index(setup) + first_build = next( + index + for index, entry in enumerate(steps(job_data)) + if "cmake" in entry.get("run", "") + and ("-S " in entry["run"] or "run.cmake" in entry["run"]) + ) + self.assertLess(position, first_build) + keys.append((job_data["runs-on"], setup["with"]["key"])) + self.assertTrue(keys) + self.assertEqual( + uses(job(workflow, "ubuntu"), "./.github/actions/setup-ccache"), [] ) def test_consumer_reference_ownership_preserves_integration_checks(self): @@ -527,19 +625,56 @@ def test_consumer_reference_ownership_preserves_integration_checks(self): """ ) helper.chmod(0o755) - for index, (openmp, reference, component, expected, fail) in enumerate( + all_methods = {"ADD_SUBDIRECTORY", "FETCHCONTENT", "PACKAGE"} + for index, ( + openmp, + reference, + component, + integrations, + expected, + fail, + ) in enumerate( [ - ("ON", "OFF", None, {"core": "OFF", "fastscan": "OFF"}, False), - ("OFF", "ON", None, {"core": "OFF", "fastscan": "ON"}, False), - ("ON", None, None, {"core": "OFF", "fastscan": "ON"}, False), - ("OFF", "OFF", "core", {"core": "OFF"}, False), - ("ON", None, "core", {"core": "ON"}, False), - ("OFF", "ON", "fastscan", {"fastscan": "ON"}, False), - ("OFF", "ON", "fastscan", {"fastscan": "ON"}, True), + ( + "ON", + "OFF", + None, + None, + {"core": "OFF", "fastscan": "OFF"}, + False, + ), + ("OFF", "ON", None, None, {"core": "OFF", "fastscan": "ON"}, False), + ("ON", None, None, None, {"core": "OFF", "fastscan": "ON"}, False), + ("OFF", "OFF", "core", None, {"core": "OFF"}, False), + ("ON", None, "core", None, {"core": "ON"}, False), + ("OFF", "ON", "fastscan", None, {"fastscan": "ON"}, False), + ("OFF", "ON", "fastscan", None, {"fastscan": "ON"}, True), + ( + "ON", + "OFF", + None, + "ALL", + {"core": "OFF", "fastscan": "OFF"}, + False, + ), + ( + "OFF", + "ON", + None, + "PACKAGE", + {"core": "OFF", "fastscan": "ON"}, + False, + ), + ("ON", "OFF", "core", "PACKAGE", {"core": "OFF"}, False), ] ): + methods = {"PACKAGE"} if integrations == "PACKAGE" else all_methods with self.subTest( - openmp=openmp, reference=reference, component=component, fail=fail + openmp=openmp, + reference=reference, + component=component, + integrations=integrations, + fail=fail, ): log.unlink(missing_ok=True) command = [ @@ -554,6 +689,8 @@ def test_consumer_reference_ownership_preserves_integration_checks(self): command.append(f"-DRABITQ_RUN_REFERENCE_TESTS={reference}") if component is not None: command.append(f"-DRABITQ_COMPONENT={component}") + if integrations is not None: + command.append(f"-DRABITQ_INTEGRATIONS={integrations}") result = subprocess.run( [*command, "-P", str(ROOT / "tests/core_consumer/run.cmake")], env=dict( @@ -599,7 +736,7 @@ def test_consumer_reference_ownership_preserves_integration_checks(self): for path in tests if path.parent.name == name and path.name != "producer" }, - {"ADD_SUBDIRECTORY", "FETCHCONTENT", "PACKAGE"}, + methods, ) self.assertEqual( any(path.name == "benchmark" for path in tests), @@ -608,14 +745,31 @@ def test_consumer_reference_ownership_preserves_integration_checks(self): def test_component_partition_rejects_unknown_or_empty_selection(self): with tempfile.TemporaryDirectory() as directory: - for component in ("unknown", ""): - with self.subTest(component=component): + for option, value, message in ( + ( + "RABITQ_COMPONENT", + "unknown", + "RABITQ_COMPONENT must be core or fastscan", + ), + ("RABITQ_COMPONENT", "", "RABITQ_COMPONENT must be core or fastscan"), + ( + "RABITQ_INTEGRATIONS", + "FETCHCONTENT", + "RABITQ_INTEGRATIONS must be ALL or PACKAGE", + ), + ( + "RABITQ_INTEGRATIONS", + "", + "RABITQ_INTEGRATIONS must be ALL or PACKAGE", + ), + ): + with self.subTest(option=option, value=value): result = subprocess.run( [ "cmake", f"-DRABITQ_SOURCE_DIR={ROOT}", f"-DWORK_DIR={directory}", - f"-DRABITQ_COMPONENT={component}", + f"-D{option}={value}", "-P", str(ROOT / "tests/core_consumer/run.cmake"), ], @@ -624,23 +778,25 @@ def test_component_partition_rejects_unknown_or_empty_selection(self): check=False, ) self.assertNotEqual(result.returncode, 0) - self.assertIn( - "RABITQ_COMPONENT must be core or fastscan", result.stderr - ) + self.assertIn(message, result.stderr) self.assertFalse(list(Path(directory).iterdir())) def test_documentation_summary_is_available_for_non_doc_prs(self): - text = (ROOT / ".github/workflows/docs.yml").read_text() - triggers = text.split("concurrency:", 1)[0] - self.assertNotIn("paths:", triggers) - self.assertNotIn("paths-ignore:", triggers) - self.assertIn(" pull_request:", triggers) - self.assertIn(" workflow_dispatch:", triggers) - self.assertIn("uses: ./.github/workflows/changes.yml", text) - build = text.split(" build:\n", 1)[1].split(" docs-result:\n", 1)[0] - self.assertIn("needs: changes", build) - self.assertIn("needs.changes.outputs.docs == 'true'", build) - self.assertIn("needs.changes.result != 'success'", build) + workflow = load_workflow("docs.yml") + triggers = workflow["on"] + for event in triggers.values(): + if isinstance(event, dict): + self.assertNotIn("paths", event) + self.assertNotIn("paths-ignore", event) + self.assertIn("pull_request", triggers) + self.assertIn("workflow_dispatch", triggers) + self.assertEqual( + job(workflow, "changes")["uses"], "./.github/workflows/changes.yml" + ) + build = job(workflow, "build") + self.assertEqual(needs(build), ["changes"]) + self.assertIn("needs.changes.outputs.docs == 'true'", build["if"]) + self.assertIn("needs.changes.result != 'success'", build["if"]) self.assertEqual(classify(["docs/docs/quick_start.md"]), {"docs"}) diff --git a/.github/scripts/workflow_data.py b/.github/scripts/workflow_data.py new file mode 100644 index 00000000..fbc4104b --- /dev/null +++ b/.github/scripts/workflow_data.py @@ -0,0 +1,51 @@ +"""Load GitHub Actions workflows as data for CI contract tests.""" + +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOWS = ROOT / ".github/workflows" + + +def load_workflow(filename): + """Parse a workflow, restoring the `on` key that YAML 1.1 reads as true.""" + data = yaml.safe_load((WORKFLOWS / filename).read_text()) + if True in data: + data["on"] = data.pop(True) + return data + + +def job(workflow, name): + return workflow["jobs"][name] + + +def needs(job_data): + value = job_data.get("needs", []) + return [value] if isinstance(value, str) else list(value) + + +def steps(job_data): + return job_data.get("steps", []) + + +def step(job_data, name): + """Return the single step with this name.""" + matches = [entry for entry in steps(job_data) if entry.get("name") == name] + if len(matches) != 1: + raise AssertionError(f"expected one step named {name!r}, found {len(matches)}") + return matches[0] + + +def runs(job_data): + """Return every shell command of a job, in step order.""" + return [entry["run"] for entry in steps(job_data) if "run" in entry] + + +def uses(job_data, action): + """Return the steps that use an action, ignoring its version suffix.""" + return [ + entry + for entry in steps(job_data) + if entry.get("uses", "").split("@", 1)[0] == action + ] diff --git a/.github/workflows/changes.yml b/.github/workflows/changes.yml index 755ad849..3f1c5df1 100644 --- a/.github/workflows/changes.yml +++ b/.github/workflows/changes.yml @@ -9,6 +9,8 @@ on: value: ${{ jobs.detect.outputs.docs }} cpp: value: ${{ jobs.detect.outputs.cpp }} + cpp_core: + value: ${{ jobs.detect.outputs.cpp_core }} cpp_format: value: ${{ jobs.detect.outputs.cpp_format }} cpp_tidy: @@ -33,6 +35,7 @@ jobs: benchmarks: ${{ steps.paths.outputs.benchmarks }} docs: ${{ steps.paths.outputs.docs }} cpp: ${{ steps.paths.outputs.cpp }} + cpp_core: ${{ steps.paths.outputs.cpp_core }} cpp_format: ${{ steps.paths.outputs.cpp_format }} cpp_tidy: ${{ steps.paths.outputs.cpp_tidy }} python_quality: ${{ steps.paths.outputs.python_quality }} @@ -40,12 +43,12 @@ jobs: shell: ${{ steps.paths.outputs.shell }} wheels: ${{ steps.paths.outputs.wheels }} steps: + # Path selection needs commit history and trees, not file contents. - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: actions/setup-python@v7 - with: - python-version: "3.11" + filter: blob:none + # The hosted Ubuntu Python provides tomllib; no interpreter setup is needed. - name: Select affected checks id: paths - run: python .github/scripts/ci_changes.py + run: python3 .github/scripts/ci_changes.py diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 3658e8e6..fd107788 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -30,7 +30,7 @@ jobs: python-version: "3.11" cache: pip - name: Install validation dependencies - run: python -m pip install "ruff==0.16.1" numpy pytest + run: python -m pip install "ruff==0.16.1" numpy pytest pyyaml - name: Check Python run: ./scripts/check-python.sh - name: Check release automation @@ -46,10 +46,12 @@ jobs: if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.python_build == 'true') }} name: Python ${{ matrix.python-version }} runs-on: ubuntu-latest + # Release wheel jobs build and fully test the oldest and newest ABIs on every PR, so + # one source build covers typing, editor metadata and source-installation smoke tests. strategy: fail-fast: false matrix: - python-version: ["3.11", "3.14"] + python-version: ["3.11"] steps: - uses: actions/checkout@v7 @@ -99,10 +101,8 @@ jobs: tests/python/test_qgkmeans.py::test_train_populates_faiss_style_outputs tests/python/test_rabitqkmeans.py::test_flat_parameters_and_outputs - name: Install FAISS comparison dependency - if: matrix.python-version == '3.11' run: python -m pip install faiss-cpu - name: Test comparison script - if: matrix.python-version == '3.11' env: OMP_NUM_THREADS: "2" run: python -m pytest tests/python/test_compare_with_faiss.py --tb=short -q diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56c23e07..a9b1fd92 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -139,7 +139,8 @@ jobs: run: test "$BUILD_RESULT" = success windows-wheel-build: - name: Windows x86-64 wheel builds + # Windows wheels take longest, so each ABI builds on its own runner. + name: Windows x86-64 wheel build (${{ matrix.abi }}) needs: [prepare, changes] if: >- !cancelled() && needs.prepare.result == 'success' && @@ -147,6 +148,10 @@ jobs: (needs.changes.result != 'success' || needs.changes.outputs.wheels == 'true') runs-on: windows-2025-vs2026 timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + abi: ${{ fromJSON(github.event_name == 'pull_request' && '["cp311", "cp314"]' || '["cp311", "cp312", "cp313", "cp314"]') }} steps: - uses: actions/checkout@v7 with: @@ -157,13 +162,13 @@ jobs: - name: Build and test wheels uses: pypa/cibuildwheel@v4.2.0 env: - CIBW_BUILD: ${{ github.event_name == 'pull_request' && 'cp311-win_amd64 cp314-win_amd64' || 'cp311-win_amd64 cp312-win_amd64 cp313-win_amd64 cp314-win_amd64' }} + CIBW_BUILD: ${{ matrix.abi }}-win_amd64 with: output-dir: wheelhouse - name: Store wheel artifact uses: actions/upload-artifact@v7 with: - name: windows-wheels + name: windows-wheels-${{ matrix.abi }} path: wheelhouse/*.whl if-no-files-found: error @@ -265,7 +270,7 @@ jobs: - name: Download wheel artifacts uses: actions/download-artifact@v8 with: - pattern: '*-wheels' + pattern: '*-wheels*' path: wheelhouse merge-multiple: true @@ -293,7 +298,7 @@ jobs: - name: Download wheel artifacts uses: actions/download-artifact@v8 with: - pattern: '*-wheels' + pattern: '*-wheels*' path: wheelhouse merge-multiple: true diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c29d7133..b0971a30 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -66,10 +66,13 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@v7 - - name: Install ShellCheck + - name: Use the runner's ShellCheck run: | - sudo apt-get update -qq - sudo apt-get install -y shellcheck + if ! command -v shellcheck >/dev/null; then + sudo apt-get update -qq + sudo apt-get install -y shellcheck + fi + shellcheck --version - name: Check scripts run: | files=(scripts/*.sh) @@ -94,6 +97,9 @@ jobs: - uses: actions/setup-python@v7 with: python-version: "3.11" + - uses: ./.github/actions/setup-ccache + with: + key: gcc-benchmarks - name: Install build dependencies run: | cmake --version @@ -126,6 +132,8 @@ jobs: - uses: actions/setup-python@v7 with: python-version: "3.11" + # No compiler cache: ccache keys -march=native literally, so objects built for one + # runner's CPU could be restored on another runner without those instructions. - name: Verify runner build tools run: | cmake --version @@ -175,15 +183,14 @@ jobs: CC: clang-23 CXX: clang++-23 OMP_NUM_THREADS: "2" - CMAKE_CXX_COMPILER_LAUNCHER: ccache steps: - uses: actions/checkout@v7 - uses: ./.github/actions/setup-clang - - name: Install compiler cache - run: | - sudo apt-get install -y ccache - echo "CCACHE_DIR=$RUNNER_TEMP/ccache" >> "$GITHUB_ENV" - # OpenMP OFF runs the full core/FastScan suites in the integrations below. + - uses: ./.github/actions/setup-ccache + with: + key: clang-openmp-${{ matrix.openmp }} + # OpenMP OFF runs the full core/FastScan suites in the integrations below; the + # GCC core consumers own the add_subdirectory and FetchContent integrations. - name: Configure portable build if: matrix.openmp == 'ON' run: >- @@ -213,6 +220,7 @@ jobs: -DWORK_DIR="$RUNNER_TEMP/rabitq-clang-core" -DRABITQ_USE_OPENMP=${{ matrix.openmp }} -DRABITQ_RUN_REFERENCE_TESTS=${{ matrix.openmp == 'OFF' && 'ON' || 'OFF' }} + -DRABITQ_INTEGRATIONS=PACKAGE -P tests/core_consumer/run.cmake - name: Report compiler cache statistics if: ${{ always() }} @@ -273,6 +281,9 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@v7 + - uses: ./.github/actions/setup-ccache + with: + key: gcc-sanitizers - name: Verify runner build tools run: | cmake --version @@ -315,6 +326,9 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@v7 + - uses: ./.github/actions/setup-ccache + with: + key: gcc-portable - name: Verify runner build tools run: | cmake --version @@ -370,16 +384,19 @@ jobs: core-consumer: name: Core and FastScan consumers (${{ matrix.os }}, OpenMP ${{ matrix.openmp }}${{ matrix.component && format(', {0}', matrix.component) || '' }}) needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.cpp == 'true') }} + if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.cpp_core == 'true') }} strategy: fail-fast: false matrix: os: [ubuntu-latest, ubuntu-24.04-arm, macos-15] openmp: ["ON", "OFF"] include: + # add_subdirectory and FetchContent run on GCC and on one MSVC partition; + # other partitions check the installed package and the reference suites. - os: windows-2025-vs2026 openmp: "ON" component: core + integrations: ALL - os: windows-2025-vs2026 openmp: "OFF" component: core @@ -393,21 +410,13 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@v7 - - name: Install Linux compiler cache - if: runner.os == 'Linux' - run: sudo apt-get update -qq && sudo apt-get install -y ccache - - name: Install Apple compiler cache and optional OpenMP - if: runner.os == 'macOS' - run: | - brew install ccache - if [ "${{ matrix.openmp }}" = ON ]; then - brew install libomp - fi - - name: Configure compiler cache + - uses: ./.github/actions/setup-ccache if: runner.os != 'Windows' - run: | - echo "CMAKE_CXX_COMPILER_LAUNCHER=ccache" >> "$GITHUB_ENV" - echo "CCACHE_DIR=$RUNNER_TEMP/ccache" >> "$GITHUB_ENV" + with: + key: core-consumer-openmp-${{ matrix.openmp }} + - name: Install optional Apple OpenMP + if: runner.os == 'macOS' && matrix.openmp == 'ON' + run: brew install libomp - name: Test external core-only integrations (Linux) if: runner.os == 'Linux' run: >- @@ -415,6 +424,7 @@ jobs: -DWORK_DIR="$RUNNER_TEMP/rabitq-core" -DRABITQ_USE_OPENMP=${{ matrix.openmp }} -DRABITQ_RUN_REFERENCE_TESTS=${{ matrix.openmp == 'OFF' && matrix.component != 'core' && 'ON' || 'OFF' }} + -DRABITQ_INTEGRATIONS=${{ (matrix.os == 'ubuntu-latest' || matrix.integrations == 'ALL') && 'ALL' || 'PACKAGE' }} -P tests/core_consumer/run.cmake - name: Test external core-only integrations (macOS) if: runner.os == 'macOS' @@ -423,6 +433,7 @@ jobs: -DWORK_DIR="$RUNNER_TEMP/rabitq-core" -DRABITQ_USE_OPENMP=${{ matrix.openmp }} -DRABITQ_RUN_REFERENCE_TESTS=${{ matrix.openmp == 'OFF' && matrix.component != 'core' && 'ON' || 'OFF' }} + -DRABITQ_INTEGRATIONS=${{ (matrix.os == 'ubuntu-latest' || matrix.integrations == 'ALL') && 'ALL' || 'PACKAGE' }} -DOpenMP_ROOT="$(brew --prefix)/opt/libomp" -P tests/core_consumer/run.cmake - name: Test external core-only integrations (Windows) @@ -437,6 +448,7 @@ jobs: -DRABITQ_COMPONENT=${{ matrix.component }} -DRABITQ_USE_OPENMP=${{ matrix.openmp }} -DRABITQ_RUN_REFERENCE_TESTS=${{ matrix.openmp == 'OFF' && matrix.component != 'core' && 'ON' || 'OFF' }} + -DRABITQ_INTEGRATIONS=${{ (matrix.os == 'ubuntu-latest' || matrix.integrations == 'ALL') && 'ALL' || 'PACKAGE' }} -P tests/core_consumer/run.cmake - name: Report compiler cache statistics if: ${{ always() && runner.os != 'Windows' }} @@ -452,7 +464,7 @@ jobs: - name: Require selected matrix checks env: CHANGES_RESULT: ${{ needs.changes.result }} - CPP_SELECTED: ${{ needs.changes.outputs.cpp }} + CPP_SELECTED: ${{ needs.changes.outputs.cpp_core }} MATRIX_RESULT: ${{ needs.core-consumer.result }} run: | if [ "$CHANGES_RESULT" = success ] && [ "$CPP_SELECTED" = false ]; then @@ -473,6 +485,9 @@ jobs: - uses: actions/checkout@v7 - name: Install build tools run: sudo apt-get update -qq && sudo apt-get install -y cmake ninja-build + - uses: ./.github/actions/setup-ccache + with: + key: gcc-portable - name: Configure portable ARM64 build run: | test "$(uname -m)" = aarch64 @@ -504,6 +519,9 @@ jobs: - uses: actions/checkout@v7 - name: Install build tools and existing OpenMP dependency run: brew install cmake ninja libomp + - uses: ./.github/actions/setup-ccache + with: + key: appleclang-portable - name: Configure portable ARM64 source groups run: | test "$(uname -m)" = arm64 diff --git a/docs/maintaining.md b/docs/maintaining.md index 5ee9046b..3e8d9785 100644 --- a/docs/maintaining.md +++ b/docs/maintaining.md @@ -23,7 +23,7 @@ only the affected job groups: | `tests/core_consumer/`, `tests/fixtures/core_api_v1/` | C++ checks; the frozen v1 fixture is excluded from formatting | | `tests/core_consumer/run.cmake`, `scripts/check-format.sh`, `scripts/check-tidy.sh` | Their usual checks, plus Python quality to run their failure-path regression tests | | C++ headers or library sources | C++ formatting, clang-tidy, platform tests, sanitizers, consumer build, and Python builds/wheels | -| C++ tests or examples | C++ formatting and platform tests; no clang-tidy | +| C++ tests or examples | C++ formatting and platform tests; no clang-tidy. Index/clustering-only sources, tests and examples skip the core/FastScan consumer matrix | | `tests/allocation/`, `tests/failures/` | C++ formatting and platform tests, including sanitizer and allocation-failure coverage; no Python builds or wheels | | Compiled Python bindings | C++ formatting and clang-tidy, plus Python builds and wheel tests | | Python tests | Python lint, source-install smoke tests, and wheel tests | @@ -49,22 +49,39 @@ Pushes compare the complete pushed range; pull requests compare against their base. Renames and deletions are included. Relevant jobs also run if change detection fails, so a detection error cannot silently waive a check. -Linux and macOS core/FastScan consumer jobs and the Clang matrix use a job-local -`ccache` through CMake's compiler launcher. Independent producer builds and -installed-package, `add_subdirectory`, and FetchContent consumer checks are retained; identical -compilations can reuse compiler output within the job. Cache statistics are printed -after testing. Compiler caches are not uploaded or shared between workflow runs. -Windows keeps its existing build path without a compiler cache. +Every Linux and macOS job that compiles the library with native tuning disabled uses +`ccache` through CMake's compiler launcher, set up by `.github/actions/setup-ccache`. +Each job saves its cache under a per-configuration key and the commit, and later runs +restore the newest matching cache, so unchanged translation units are not recompiled +between runs. Identical compilations within one job also reuse each other's output. +The native-tuned `Ubuntu` job has no compiler cache: ccache keys `-march=native` +literally, so objects built for one runner's CPU could otherwise be restored on a +runner without those instructions. Windows keeps its existing Visual Studio build +path, which does not support compiler launchers. + +The C++ change selection has a separate `cpp_core` output for the core/FastScan +consumer matrix. Paths that only affect ANN indexes or clustering, such as +`src/index/`, index/clustering headers and tests, the installed index consumer and +the index examples, select the other C++ checks but not that matrix. Numerical reference suites have one owner per compiler/platform and OpenMP mode. The full C++ jobs own OpenMP ON references; component consumer jobs retain their build/install/header/symbol checks without rebuilding those suites. OpenMP OFF runs the combined core/FastScan reference suite in the FastScan producer only. Across the component and Clang consumer matrices, this reduces producer reference -builds from 20 to 5 while preserving all 60 consumer integration configurations. +builds from 20 to 5. The `add_subdirectory` and FetchContent source integrations run +for both OpenMP modes on the GCC Ubuntu partition and on the MSVC core OpenMP ON +partition; the other partitions pass `-DRABITQ_INTEGRATIONS=PACKAGE` and check only +the relocated installed package. This keeps 30 of the former 60 consumer integration +configurations, with every compiler/platform and OpenMP mode still consuming the +installed package. `C++ static analysis` runs the complete configured library/binding compilation database in one job, replacing the two shards and their summary job. +Windows wheels build one CPython ABI per runner because a single Windows job +was the longest check in a pull request; the other platforms build their ABIs +sequentially in one job, and every platform keeps its existing summary check. + Release tags and manual wheel builds always run the full wheel build. CI routing edits run the full relevant matrix on pull requests. After an already released version reaches `main`, a push containing routing edits avoids repeated @@ -118,11 +135,12 @@ commit-specific groups, and release serialization is unchanged. `Clang validation`, `Core and FastScan validation`, and `Python validation` provide stable check names for the corresponding matrices. `Python validation` -covers the Linux Python 3.11/3.14 source-install matrix; Windows Python and wheel +covers the Linux Python 3.11 source-install job; the release wheel jobs build and fully +test both the oldest and newest ABIs on pull requests. Windows Python and wheel checks retain their separate gates. They run even when a dependency fails or is skipped. A successful matrix passes. A skipped matrix passes only when change detection succeeded and explicitly selected no checks for that matrix -(`cpp` or `python_build`); failure, cancellation, +(`cpp`, `cpp_core` or `python_build`); failure, cancellation, or an unexpected skip fails the summary. If detection fails, the existing fallback runs the matrix and its success is required. @@ -168,7 +186,9 @@ partial release, rerun the failed release jobs: existing tags must still point to the tested commit, and already uploaded PyPI files are skipped. From the repository root, using your activated project Python environment, -validate automation changes locally with: +validate automation changes locally with the command below. The workflow contract +tests parse workflows with PyYAML, which the Python quality job installs alongside +Ruff; install it in your environment if it is missing. ```bash python -m unittest discover -s .github/scripts -p 'test_*.py' diff --git a/tests/README.md b/tests/README.md index 9bc31251..c9f7a06b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -139,8 +139,8 @@ These are correctness checks, not hardware performance measurements. Source-install Python jobs check imports, index persistence, and both clustering APIs. Repaired-wheel jobs run the full core Python suite. Large-dimension Python -cases check representative binding/persistence paths; C++ retains the exhaustive -clustering dimension/storage matrix. +cases check representative binding/persistence paths; C++ covers each clustering +dimension and storage layout. ## Python tests @@ -230,8 +230,11 @@ cmake -DRABITQ_SOURCE_DIR= -DWORK_DIR= -P Use `-DRABITQ_RUN_REFERENCE_TESTS=OFF` for integration-only checks when references have already run in the matching full build. CI uses this for OpenMP ON; OpenMP OFF -keeps one combined reference run per compiler/platform. Consumer tests, header/symbol -checks, invalid-configuration checks and the core benchmark always remain enabled. +keeps one combined reference run per compiler/platform. Pass +`-DRABITQ_INTEGRATIONS=PACKAGE` to test only the relocated installed package when +another job owns the `add_subdirectory` and FetchContent integrations; the default +`ALL` runs all three. Consumer tests, header/symbol checks, invalid-configuration checks +and the core benchmark always remain enabled. On macOS, also pass `-DOpenMP_ROOT=`. On Windows, set `CMAKE_GENERATOR=Visual Studio 18 2026` and `CMAKE_GENERATOR_PLATFORM=x64` in the @@ -280,7 +283,8 @@ See [the fixture policy](fixtures/core_api_v1/README.md). ## Test structure - `unit/`: C++ index, quantization, FastScan, and utility tests -- `common/`: shared C++ test utilities +- `common/`: shared C++ test utilities; use `rabitq_test::TempFile` for files a test + writes so they are removed even after a failed assertion - `python/`: Python binding tests and persistence fixtures - `consumer/`: installed CMake package test diff --git a/tests/core_consumer/run.cmake b/tests/core_consumer/run.cmake index ad58ee4e..d80bd713 100644 --- a/tests/core_consumer/run.cmake +++ b/tests/core_consumer/run.cmake @@ -1,6 +1,8 @@ # Run with -DRABITQ_SOURCE_DIR= -DWORK_DIR= -P run.cmake. # Optional -DRABITQ_COMPONENT=core|fastscan selects one independent partition. # Set RABITQ_RUN_REFERENCE_TESTS=OFF when another job owns the numerical suite. +# RABITQ_INTEGRATIONS=PACKAGE checks only the installed package when another job +# owns the add_subdirectory and FetchContent source integrations (default: ALL). cmake_minimum_required(VERSION 3.20) if(NOT RABITQ_SOURCE_DIR OR NOT WORK_DIR) message(FATAL_ERROR "RABITQ_SOURCE_DIR and WORK_DIR are required") @@ -20,6 +22,16 @@ endif() if(NOT DEFINED RABITQ_RUN_REFERENCE_TESTS) set(RABITQ_RUN_REFERENCE_TESTS ON) endif() +if(NOT DEFINED RABITQ_INTEGRATIONS) + set(RABITQ_INTEGRATIONS ALL) +endif() +if(RABITQ_INTEGRATIONS STREQUAL "ALL") + set(integration_methods ADD_SUBDIRECTORY FETCHCONTENT PACKAGE) +elseif(RABITQ_INTEGRATIONS STREQUAL "PACKAGE") + set(integration_methods PACKAGE) +else() + message(FATAL_ERROR "RABITQ_INTEGRATIONS must be ALL or PACKAGE") +endif() list(APPEND configure_options "-DRABITQ_USE_OPENMP=${RABITQ_USE_OPENMP}") if(NOT RABITQ_USE_OPENMP) list(APPEND configure_options -DCMAKE_DISABLE_FIND_PACKAGE_OpenMP=TRUE) @@ -74,7 +86,7 @@ foreach(component IN LISTS components) --prefix "${work}/install") file(RENAME "${work}/install" "${work}/relocated") endif() - foreach(method IN ITEMS ADD_SUBDIRECTORY FETCHCONTENT PACKAGE) + foreach(method IN LISTS integration_methods) set(build "${work}/${method}") set(consumer_source "${RABITQ_SOURCE_DIR}") if(method STREQUAL "FETCHCONTENT") From 302ddfbdaae677365eed936c512b0a0d44e7076b Mon Sep 17 00:00:00 2001 From: gouyt13clear Date: Sat, 31 Oct 2026 05:00:22 +0800 Subject: [PATCH 3/3] ci: save compiler caches only from main Pull requests now restore the newest cache saved from main and never save, so their per-commit entries cannot fill the repository cache quota and evict the main cache that every pull request starts from. --- .github/actions/save-ccache/action.yml | 16 ++++++++ .github/actions/setup-ccache/action.yml | 16 +++++--- .github/scripts/test_ci_workflow_contracts.py | 40 ++++++++++++++++--- .github/workflows/test.yaml | 20 +++++++--- docs/maintaining.md | 8 ++-- 5 files changed, 79 insertions(+), 21 deletions(-) create mode 100644 .github/actions/save-ccache/action.yml diff --git a/.github/actions/save-ccache/action.yml b/.github/actions/save-ccache/action.yml new file mode 100644 index 00000000..5347b2e2 --- /dev/null +++ b/.github/actions/save-ccache/action.yml @@ -0,0 +1,16 @@ +name: Save ccache +description: >- + Report the statistics of the cache set up by setup-ccache and save it on pushes to main. + Pull requests only restore, so they cannot evict the cache every pull request starts from. +runs: + using: composite + steps: + - name: Report compiler cache statistics + shell: bash + run: ccache --show-stats + - name: Save compiler cache + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/cache/save@v6 + with: + path: ${{ runner.temp }}/ccache + key: ${{ env.CCACHE_KEY_PREFIX }}${{ github.sha }} diff --git a/.github/actions/setup-ccache/action.yml b/.github/actions/setup-ccache/action.yml index 8c5417b4..b396a74b 100644 --- a/.github/actions/setup-ccache/action.yml +++ b/.github/actions/setup-ccache/action.yml @@ -1,7 +1,8 @@ name: Set up ccache description: >- - Install ccache, route CMake compilations through it and keep its cache between runs - on Linux and macOS. Visual Studio generators do not support compiler launchers. + Install ccache, route CMake compilations through it and restore the newest cache saved + from main on Linux and macOS. Pair it with save-ccache as the job's last step. Visual + Studio generators do not support compiler launchers. inputs: key: description: Cache partition for one compiler and build configuration. @@ -11,6 +12,8 @@ runs: steps: - name: Install ccache shell: bash + env: + CACHE_PARTITION: ${{ inputs.key }} run: | set -euo pipefail if ! command -v ccache >/dev/null 2>&1; then @@ -26,16 +29,17 @@ runs: echo "CCACHE_DIR=$RUNNER_TEMP/ccache" echo "CCACHE_BASEDIR=$GITHUB_WORKSPACE" echo "CCACHE_MAXSIZE=500M" + echo "CCACHE_KEY_PREFIX=ccache-$RUNNER_OS-$RUNNER_ARCH-$CACHE_PARTITION-" echo "CMAKE_C_COMPILER_LAUNCHER=ccache" echo "CMAKE_CXX_COMPILER_LAUNCHER=ccache" } >> "$GITHUB_ENV" - # Each run saves under its commit; later runs restore the newest matching partition. + # Only main saves (see save-ccache); every run restores the newest matching partition. - name: Restore compiler cache - uses: actions/cache@v6 + uses: actions/cache/restore@v6 with: path: ${{ runner.temp }}/ccache - key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ inputs.key }}-${{ github.sha }} - restore-keys: ccache-${{ runner.os }}-${{ runner.arch }}-${{ inputs.key }}- + key: ${{ env.CCACHE_KEY_PREFIX }}${{ github.sha }} + restore-keys: ${{ env.CCACHE_KEY_PREFIX }} - name: Reset compiler cache statistics shell: bash run: ccache --zero-stats diff --git a/.github/scripts/test_ci_workflow_contracts.py b/.github/scripts/test_ci_workflow_contracts.py index 0df7efc4..e3905b47 100644 --- a/.github/scripts/test_ci_workflow_contracts.py +++ b/.github/scripts/test_ci_workflow_contracts.py @@ -549,13 +549,34 @@ def test_source_integrations_keep_one_gcc_and_one_msvc_owner(self): def test_compiling_unix_jobs_restore_a_compiler_cache(self): workflow = load_workflow("test.yaml") - action = yaml.safe_load( - (ROOT / ".github/actions/setup-ccache/action.yml").read_text() + (restore,) = [ + entry + for entry in yaml.safe_load( + (ROOT / ".github/actions/setup-ccache/action.yml").read_text() + )["runs"]["steps"] + if "uses" in entry + ] + (save,) = [ + entry + for entry in yaml.safe_load( + (ROOT / ".github/actions/save-ccache/action.yml").read_text() + )["runs"]["steps"] + if "uses" in entry + ] + # Pull requests only restore, so they cannot evict the cache saved from main. + self.assertTrue(restore["uses"].startswith("actions/cache/restore@")) + self.assertTrue(save["uses"].startswith("actions/cache/save@")) + self.assertEqual( + save["if"], "github.event_name == 'push' && github.ref == 'refs/heads/main'" + ) + self.assertEqual( + save["with"], + {"path": restore["with"]["path"], "key": restore["with"]["key"]}, + ) + self.assertIn("${{ github.sha }}", restore["with"]["key"]) + self.assertTrue( + restore["with"]["key"].startswith(restore["with"]["restore-keys"]) ) - (cache,) = [entry for entry in action["runs"]["steps"] if "uses" in entry] - self.assertTrue(cache["uses"].startswith("actions/cache@")) - self.assertIn("${{ github.sha }}", cache["with"]["key"]) - self.assertTrue(cache["with"]["key"].startswith(cache["with"]["restore-keys"])) keys = [] for name, job_data in workflow["jobs"].items(): commands = runs(job_data) @@ -587,11 +608,18 @@ def test_compiling_unix_jobs_restore_a_compiler_cache(self): and ("-S " in entry["run"] or "run.cmake" in entry["run"]) ) self.assertLess(position, first_build) + # Saving after every build step keeps all of the job's compilations. + saves = uses(job_data, "./.github/actions/save-ccache") + self.assertEqual(saves, [steps(job_data)[-1]], "save ccache last") + self.assertIn("!cancelled()", saves[0]["if"]) keys.append((job_data["runs-on"], setup["with"]["key"])) self.assertTrue(keys) self.assertEqual( uses(job(workflow, "ubuntu"), "./.github/actions/setup-ccache"), [] ) + for workflow_path in (ROOT / ".github").rglob("*.y*ml"): + with self.subTest(file=workflow_path.name): + self.assertNotIn("actions/cache@", workflow_path.read_text()) def test_consumer_reference_ownership_preserves_integration_checks(self): with tempfile.TemporaryDirectory() as directory: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b0971a30..41d0809a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -120,6 +120,8 @@ jobs: run: cmake --build build-benchmarks --parallel 2 - name: Run bounded benchmark smoke tests run: ctest --test-dir build-benchmarks --output-on-failure --no-tests=error --timeout 60 + - uses: ./.github/actions/save-ccache + if: ${{ !cancelled() }} ubuntu: needs: changes @@ -222,9 +224,8 @@ jobs: -DRABITQ_RUN_REFERENCE_TESTS=${{ matrix.openmp == 'OFF' && 'ON' || 'OFF' }} -DRABITQ_INTEGRATIONS=PACKAGE -P tests/core_consumer/run.cmake - - name: Report compiler cache statistics - if: ${{ always() }} - run: ccache --show-stats + - uses: ./.github/actions/save-ccache + if: ${{ !cancelled() }} clang-result: name: Clang validation @@ -317,6 +318,8 @@ jobs: -DRABITQ_EXPECT_SANITIZERS=ON cmake --build build-sanitized-consumer ctest --test-dir build-sanitized-consumer --output-on-failure --no-tests=error + - uses: ./.github/actions/save-ccache + if: ${{ !cancelled() }} downstream-consumer: needs: changes @@ -380,6 +383,8 @@ jobs: cmake --build build-consumer - name: Test downstream consumer run: ctest --test-dir build-consumer --output-on-failure --no-tests=error + - uses: ./.github/actions/save-ccache + if: ${{ !cancelled() }} core-consumer: name: Core and FastScan consumers (${{ matrix.os }}, OpenMP ${{ matrix.openmp }}${{ matrix.component && format(', {0}', matrix.component) || '' }}) @@ -450,9 +455,8 @@ jobs: -DRABITQ_RUN_REFERENCE_TESTS=${{ matrix.openmp == 'OFF' && matrix.component != 'core' && 'ON' || 'OFF' }} -DRABITQ_INTEGRATIONS=${{ (matrix.os == 'ubuntu-latest' || matrix.integrations == 'ALL') && 'ALL' || 'PACKAGE' }} -P tests/core_consumer/run.cmake - - name: Report compiler cache statistics - if: ${{ always() && runner.os != 'Windows' }} - run: ccache --show-stats + - uses: ./.github/actions/save-ccache + if: ${{ !cancelled() && runner.os != 'Windows' }} core-consumer-result: name: Core and FastScan validation @@ -506,6 +510,8 @@ jobs: -DCMAKE_PREFIX_PATH="$RUNNER_TEMP/rabitq-install" cmake --build build-consumer --parallel 2 ctest --test-dir build-consumer --output-on-failure --no-tests=error + - uses: ./.github/actions/save-ccache + if: ${{ !cancelled() }} macos-arm64: name: Native Apple Silicon C++ @@ -542,3 +548,5 @@ jobs: -DOpenMP_ROOT="$(brew --prefix libomp)" cmake --build build-consumer --parallel 3 ctest --test-dir build-consumer --output-on-failure --no-tests=error + - uses: ./.github/actions/save-ccache + if: ${{ !cancelled() }} diff --git a/docs/maintaining.md b/docs/maintaining.md index 3e8d9785..01053a16 100644 --- a/docs/maintaining.md +++ b/docs/maintaining.md @@ -51,9 +51,11 @@ change detection fails, so a detection error cannot silently waive a check. Every Linux and macOS job that compiles the library with native tuning disabled uses `ccache` through CMake's compiler launcher, set up by `.github/actions/setup-ccache`. -Each job saves its cache under a per-configuration key and the commit, and later runs -restore the newest matching cache, so unchanged translation units are not recompiled -between runs. Identical compilations within one job also reuse each other's output. +Every run restores the newest cache saved from `main` for its per-configuration key, +so unchanged translation units are not recompiled. Only pushes to `main` save, in the +job's final `.github/actions/save-ccache` step, so pull requests cannot evict that +cache from the repository's cache quota. Identical compilations within one job also +reuse each other's output. The native-tuned `Ubuntu` job has no compiler cache: ccache keys `-march=native` literally, so objects built for one runner's CPU could otherwise be restored on a runner without those instructions. Windows keeps its existing Visual Studio build