diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8197ccd..774cb3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,9 @@ on: jobs: test: - runs-on: ubuntu-latest + # Pinned: on ubuntu-latest (26.04) HerculesRange RandomWalk and + # CoconutTest.QueryBoundLookup fail; they pass on 24.04. Tracked separately. + runs-on: ubuntu-24.04 strategy: matrix: diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 80eb11d..37afda1 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -18,7 +18,9 @@ concurrency: jobs: publish: - runs-on: ubuntu-latest + # Pinned: ubuntu-latest moved to 26.04, whose toolchain auditwheel cannot + # turn into a manylinux wheel ("too-recent versioned symbols"). + runs-on: ubuntu-24.04 permissions: contents: read diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 3f48924..44ca524 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -17,7 +17,9 @@ on: jobs: dry-run: - runs-on: ubuntu-latest + # Pinned: ubuntu-latest moved to 26.04, whose toolchain auditwheel cannot + # turn into a manylinux wheel ("too-recent versioned symbols"). + runs-on: ubuntu-24.04 permissions: contents: read diff --git a/.github/workflows/simple-ci.yml b/.github/workflows/simple-ci.yml index 036f44e..d937d17 100644 --- a/.github/workflows/simple-ci.yml +++ b/.github/workflows/simple-ci.yml @@ -6,7 +6,9 @@ on: jobs: test: - runs-on: ubuntu-latest + # Pinned: on ubuntu-latest (26.04) HerculesRange RandomWalk and + # CoconutTest.QueryBoundLookup fail; they pass on 24.04. Tracked separately. + runs-on: ubuntu-24.04 steps: - name: Checkout code diff --git a/CONTINUITY.md b/CONTINUITY.md index 48daa29..4596e9e 100644 --- a/CONTINUITY.md +++ b/CONTINUITY.md @@ -64,6 +64,11 @@ These algorithms do not all share the same dependency profile or feature set. ## Important Behavioral Conventions - Top-k search is the baseline capability. +- Every top-k entry point rejects `k` greater than the number of indexed series with + `std::invalid_argument` (`ValueError` in Python), via + `SimilaritySearchAlgorithm::checkTopKNotGreaterThanDatabase`. Results are never padded by + repeating the last neighbor or with placeholder ids. New algorithms must call it too; + `tests/test_TopK_KGreaterThanN.cpp` and `tests/test_topk_k_greater_than_n.py` cover it. - Range search is modeled through `SearchConfig` and is not universally implemented. - Streaming insert is implemented by `BruteForceSearch`, `LbBruteforce`, `Messi`, and `Coconut`; the base implementation still throws for algorithms that do not support it. diff --git a/README.md b/README.md index 8dd3c8c..63d695a 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,11 @@ use insertion counts, not arbitrary user timestamps. Resident SAX uses O(N) RAM; See [the implementation notes](docs/coconut-disk-streaming.md). Earlier results from the unmerged RAM insertion buffer do not evaluate disk-based streaming. +### Top-k with k larger than the dataset + +Every algorithm rejects a top-k query whose `k` is greater than the number of indexed series: +C++ throws `std::invalid_argument` and Python raises `ValueError`. Results are never padded. + ### Range (distance-r) queries Algorithms that implement range search answer range queries through diff --git a/lib/algos/Coconut.cpp b/lib/algos/Coconut.cpp index d4fc661..80edbf9 100644 --- a/lib/algos/Coconut.cpp +++ b/lib/algos/Coconut.cpp @@ -503,6 +503,7 @@ namespace requireBuilt(); if (distance_type != DistanceType::L2_SQUARED) throw std::runtime_error("Coconut top-k search only supports L2_SQUARED"); if (!k) throw std::runtime_error("Coconut top-k search requires k > 0"); + checkTopKNotGreaterThanDatabase(k); checkFinite(query, n_query, dim); set_active_breakpoints(nullptr, nullptr); const auto &s = *state_; auto runs = s.snapshot(); for (const auto &run : runs) run->validate(); diff --git a/lib/algos/Fresh.cpp b/lib/algos/Fresh.cpp index 1d79cae..e72c7e3 100644 --- a/lib/algos/Fresh.cpp +++ b/lib/algos/Fresh.cpp @@ -1518,6 +1518,7 @@ void Fresh::searchIndex(const float *query, idx_t n_query, const SearchConfig &c void Fresh::searchIndex(const float *query, idx_t n_query, idx_t k, idx_t *I, float *D) { + checkTopKNotGreaterThanDatabase(k); if (this->distance_type == DistanceType::L2_SQUARED) searchIndexL2Squared(query, n_query, k, I, D); else if (this->distance_type == DistanceType::DTW) diff --git a/lib/algos/Hercules.cpp b/lib/algos/Hercules.cpp index fa43a8c..7e27080 100644 --- a/lib/algos/Hercules.cpp +++ b/lib/algos/Hercules.cpp @@ -725,6 +725,7 @@ void Hercules::searchIndex(const float *query, idx_t n_query, idx_t k, idx_t *I, throw std::runtime_error("Hercules::searchIndex: index not built"); if (config_.index_dir.empty()) throw std::runtime_error("Hercules::searchIndex: index_dir not set"); + checkTopKNotGreaterThanDatabase(k); for (idx_t q = 0; q < n_query; q++) hercules_knn_search(root_, query + q * this->dim, (int)this->dim, k, diff --git a/lib/algos/Messi.cpp b/lib/algos/Messi.cpp index e510573..19426f6 100644 --- a/lib/algos/Messi.cpp +++ b/lib/algos/Messi.cpp @@ -1350,6 +1350,7 @@ namespace daisy void Messi::searchIndex(const float *query, const idx_t n_query, const idx_t k, idx_t *I, float *D) { + checkTopKNotGreaterThanDatabase(k); if (this->distance_type == DistanceType::L2_SQUARED) { searchIndexL2Squared(query, n_query, k, I, D); diff --git a/lib/algos/ParIS.cpp b/lib/algos/ParIS.cpp index 3e331b5..f8976f1 100644 --- a/lib/algos/ParIS.cpp +++ b/lib/algos/ParIS.cpp @@ -186,6 +186,7 @@ namespace daisy void ParIS::searchIndex(const float *query, const idx_t n_query, const idx_t k, idx_t *I, float *D) { + checkTopKNotGreaterThanDatabase(k); if (this->distance_type == DistanceType::L2_SQUARED) { searchIndexL2Squared(query, n_query, k, I, D); diff --git a/lib/algos/SimilaritySearchAlgorithm.hpp b/lib/algos/SimilaritySearchAlgorithm.hpp index e982708..84e9ac8 100644 --- a/lib/algos/SimilaritySearchAlgorithm.hpp +++ b/lib/algos/SimilaritySearchAlgorithm.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include "../distance_computers/DistanceComputer.hpp" #include "../isax/iSAXSearch.hpp" @@ -119,6 +120,17 @@ namespace daisy } protected: + // Top-k cannot return more neighbors than indexed series. Every top-k + // entry point calls this so the caller gets an error (std::invalid_argument, + // ValueError in Python) instead of padded or placeholder results. + void checkTopKNotGreaterThanDatabase(const idx_t k) const + { + if (k > n_database) + throw std::invalid_argument("k (" + std::to_string(k) + + ") cannot be greater than the number of indexed series (" + + std::to_string(n_database) + ")"); + } + bool validateSearchParams(const idx_t k, const idx_t n_query) const { if (k == 0) @@ -126,11 +138,7 @@ namespace daisy std::cerr << "[Error] k must be greater than 0\n"; return false; } - if (k > n_database) - { - std::cerr << "[Error] k (" << k << ") cannot be greater than database size (" << n_database << ")\n"; - return false; - } + checkTopKNotGreaterThanDatabase(k); if (n_query == 0) { std::cerr << "[Error] n_query must be greater than 0\n"; diff --git a/lib/algos/Sing.cpp b/lib/algos/Sing.cpp index 5b80059..b41e436 100644 --- a/lib/algos/Sing.cpp +++ b/lib/algos/Sing.cpp @@ -1699,6 +1699,7 @@ namespace daisy void Sing::searchIndex(const float *query, const idx_t n_query, const idx_t k, idx_t *I, float *D) { + checkTopKNotGreaterThanDatabase(k); if (this->distance_type == DistanceType::L2_SQUARED && this->index != nullptr) { searchIndexL2Square(query, n_query, k, I, D); diff --git a/lib/algos/Sofa.cpp b/lib/algos/Sofa.cpp index e0bd447..b3625ac 100644 --- a/lib/algos/Sofa.cpp +++ b/lib/algos/Sofa.cpp @@ -867,6 +867,7 @@ void Sofa::searchIndexDTW(const float *query, idx_t n_query, idx_t k, idx_t *I, void Sofa::searchIndex(const float *query, const idx_t n_query, const idx_t k, idx_t *I, float *D) { + checkTopKNotGreaterThanDatabase(k); if (this->distance_type == DistanceType::L2_SQUARED) searchIndexL2Squared(query, n_query, k, I, D); else if (this->distance_type == DistanceType::DTW) diff --git a/lib/algos/odyssey/Odyssey.cpp b/lib/algos/odyssey/Odyssey.cpp index 9e2f8bd..77ef7a9 100644 --- a/lib/algos/odyssey/Odyssey.cpp +++ b/lib/algos/odyssey/Odyssey.cpp @@ -2231,11 +2231,7 @@ namespace daisy std::cerr << "[Error] k must be greater than 0\n"; return false; } - if (k > n_database) - { - std::cerr << "[Error] k (" << k << ") cannot be greater than database size (" << n_database << ")\n"; - return false; - } + checkTopKNotGreaterThanDatabase(k); if (n_query == 0) { std::cerr << "[Error] n_query must be greater than 0\n"; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 93f21ee..d652a97 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -383,6 +383,33 @@ gtest_discover_tests( WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} ) +# ////// TOP-K: k > n is rejected by every algorithm ////// +add_executable( + test_TopK_KGreaterThanN + test_TopK_KGreaterThanN.cpp + test_utils.cpp +) +target_link_libraries( + test_TopK_KGreaterThanN + PRIVATE + GTest::gtest_main + dino_lib + commons_lib + stdc++fs +) +target_include_directories(test_TopK_KGreaterThanN + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../lib + ${CMAKE_CURRENT_SOURCE_DIR}/../commons +) +if(BUILD_COCONUT) + target_compile_definitions(test_TopK_KGreaterThanN PRIVATE DAISY_TEST_COCONUT=1) +endif() +gtest_discover_tests( + test_TopK_KGreaterThanN + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} +) + # ////// MESSI streaming ////// add_executable( test_Messi_Streaming diff --git a/tests/test_TopK_KGreaterThanN.cpp b/tests/test_TopK_KGreaterThanN.cpp new file mode 100644 index 0000000..553fe64 --- /dev/null +++ b/tests/test_TopK_KGreaterThanN.cpp @@ -0,0 +1,144 @@ +// Every top-k entry point must reject k > number of indexed series with +// std::invalid_argument (ValueError in Python) instead of returning padded or +// placeholder results. k == n must keep working. +#include "test_utils.hpp" +#if DAISY_TEST_COCONUT +#include "../lib/algos/Coconut.hpp" +#endif + +#include +#include +#include +#include +#include +#include + +using daisy::idx_t; + +namespace +{ + constexpr idx_t N = 64; + constexpr idx_t DIM = 64; + constexpr idx_t NQ = 2; + + std::vector randomSeries(idx_t n, unsigned seed) + { + std::mt19937 rng(seed); + std::normal_distribution dist(0.0f, 1.0f); + std::vector v(n * DIM); + for (float &x : v) + x = dist(rng); + return v; + } + + struct Fixture + { + std::vector data = randomSeries(N, 7); + std::vector queries = randomSeries(NQ, 11); + std::string data_file; + + Fixture() + { + data_file = ::testing::TempDir() + "daisy_topk_k_gt_n.bin"; + FILE *f = std::fopen(data_file.c_str(), "wb"); + std::fwrite(data.data(), sizeof(float), data.size(), f); + std::fclose(f); + } + ~Fixture() { std::remove(data_file.c_str()); } + }; + + void expectKGreaterThanNRejected(daisy::SimilaritySearchAlgorithm &search, const float *queries) + { + std::vector I(NQ * (N + 1)); + std::vector D(NQ * (N + 1)); + EXPECT_THROW(search.searchIndex(queries, NQ, N + 1, I.data(), D.data()), std::invalid_argument); + + daisy::SearchConfig config; + config.k = N + 1; + std::vector> VI; + std::vector> VD; + EXPECT_THROW(search.searchIndex(queries, NQ, config, VI, VD), std::invalid_argument); + + EXPECT_NO_THROW(search.searchIndex(queries, NQ, N, I.data(), D.data())); + } +} + +class TopKGreaterThanN : public ::testing::TestWithParam +{ +protected: + Fixture fx; +}; + +TEST_P(TopKGreaterThanN, BruteForceSearch) +{ + daisy::BruteForceSearch search(GetParam()); + search.buildIndex(fx.data.data(), N, DIM); + expectKGreaterThanNRejected(search, fx.queries.data()); +} + +TEST_P(TopKGreaterThanN, LbBruteforce) +{ + daisy::LbBruteforce search(GetParam()); + search.buildIndex(fx.data.data(), N, DIM); + expectKGreaterThanNRejected(search, fx.queries.data()); +} + +TEST_P(TopKGreaterThanN, Messi) +{ + daisy::Messi search(GetParam()); + search.buildIndex(fx.data.data(), N, DIM); + expectKGreaterThanNRejected(search, fx.queries.data()); +} + +TEST_P(TopKGreaterThanN, ParIS) +{ + daisy::ParIS search(GetParam()); + search.buildIndex(fx.data_file, DIM, N); + expectKGreaterThanNRejected(search, fx.queries.data()); +} + +TEST_P(TopKGreaterThanN, Fresh) +{ + daisy::Fresh search(GetParam()); + search.buildIndex(fx.data.data(), N, DIM); + expectKGreaterThanNRejected(search, fx.queries.data()); +} + +TEST_P(TopKGreaterThanN, DumpyOS) +{ + daisy::DumpyOS search(GetParam()); + search.buildIndex(fx.data.data(), N, DIM); + expectKGreaterThanNRejected(search, fx.queries.data()); +} + +INSTANTIATE_TEST_SUITE_P( + Metrics, TopKGreaterThanN, + ::testing::Values(daisy::DistanceType::L2_SQUARED, daisy::DistanceType::DTW), + [](const ::testing::TestParamInfo &info) + { return info.param == daisy::DistanceType::DTW ? std::string("DTW") : std::string("L2Squared"); }); + +// L2-only algorithms. +TEST(TopKGreaterThanNL2, Hercules) +{ + Fixture fx; + daisy::HerculesConfig cfg; + cfg.index_dir = ::testing::TempDir() + "daisy_topk_k_gt_n_hercules"; + daisy::Hercules search(daisy::DistanceType::L2_SQUARED, cfg); + search.buildIndex(fx.data.data(), N, DIM); + expectKGreaterThanNRejected(search, fx.queries.data()); +} + +#if DAISY_TEST_COCONUT +TEST(TopKGreaterThanNL2, Coconut) +{ + Fixture fx; + daisy::Coconut search(daisy::DistanceType::L2_SQUARED); + search.buildIndex(fx.data.data(), N, DIM); + expectKGreaterThanNRejected(search, fx.queries.data()); + + std::vector I(NQ * (N + 1)); + std::vector D(NQ * (N + 1)); + EXPECT_THROW(search.searchIndexRecent(fx.queries.data(), NQ, N + 1, N, I.data(), D.data()), + std::invalid_argument); +} +#endif diff --git a/tests/test_topk_k_greater_than_n.py b/tests/test_topk_k_greater_than_n.py new file mode 100644 index 0000000..371fbc6 --- /dev/null +++ b/tests/test_topk_k_greater_than_n.py @@ -0,0 +1,66 @@ +"""Top-k with k larger than the indexed series must raise ValueError, never pad.""" +import os +import tempfile +import unittest + +import numpy as np +import daisy +from daisy import DistanceType + +N, DIM = 16, 64 + + +class TopKGreaterThanNTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + rng = np.random.default_rng(3) + cls.data = np.ascontiguousarray(rng.standard_normal((N, DIM)).astype(np.float32)) + cls.queries = np.ascontiguousarray(rng.standard_normal((2, DIM)).astype(np.float32)) + cls.tmpdir = tempfile.mkdtemp() + cls.data_file = os.path.join(cls.tmpdir, "data.bin") + cls.data.tofile(cls.data_file) + + @classmethod + def tearDownClass(cls): + os.remove(cls.data_file) + os.rmdir(cls.tmpdir) + + def build(self, name, metric): + index = getattr(daisy, name)(metric) + if hasattr(index, "setWarpingWindow"): + index.setWarpingWindow(3) + if name == "ParIS": + index.buildIndex(self.data_file, DIM, N) + else: + index.buildIndex(self.data) + return index + + def test_k_greater_than_n_raises(self): + names = [n for n in ("BruteForceSearch", "LbBruteforce", "Messi", "ParIS", "Fresh", "DumpyOS") + if hasattr(daisy, n)] + for name in names: + for metric in (DistanceType.L2_SQUARED, DistanceType.DTW): + with self.subTest(algorithm=name, metric=metric): + index = self.build(name, metric) + with self.assertRaises(ValueError): + index.searchIndex(self.queries, N + 1) + if hasattr(daisy, "SearchConfig"): + config = daisy.SearchConfig() + config.k = N + 1 + with self.assertRaises(ValueError): + index.searchIndex(self.queries, config) + ids, _ = index.searchIndex(self.queries, N) + self.assertEqual(sorted(ids[0].astype(np.int64).tolist()), list(range(N))) + + def test_streaming_insert_raises_the_limit(self): + index = daisy.Messi(DistanceType.L2_SQUARED) + index.buildIndex(self.data[: N // 2]) + with self.assertRaises(ValueError): + index.searchIndex(self.queries, N // 2 + 1) + index.insertBatch(self.data[N // 2:]) + ids, _ = index.searchIndex(self.queries, N) + self.assertEqual(len(np.unique(ids[0])), N) + + +if __name__ == "__main__": + unittest.main()