From 081012b8ed52ae24bce5d0e068cd5f8ac6213837 Mon Sep 17 00:00:00 2001 From: Kristofferjoha Date: Sat, 24 Oct 2026 11:45:55 +0200 Subject: [PATCH 1/4] pruning boxes speedup of doom --- engine/CMakeLists.txt | 1 + .../backend/engine/core/trajectory_db.hpp | 20 ++- engine/include/backend/engine/query/knn.hpp | 4 +- engine/src/core/trajectory_db.cpp | 19 +++ engine/src/query/knn.cpp | 68 +++++++++- engine/tests/knn_test.cpp | 125 ++++++++++++++++++ engine/tests/subset_test.cpp | 15 +++ 7 files changed, 242 insertions(+), 10 deletions(-) create mode 100644 engine/tests/knn_test.cpp diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index d32fdbf..f2ce9eb 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -44,6 +44,7 @@ add_executable(engine_tests tests/selectors/simplify_database_test.cpp tests/range_test.cpp tests/parallel_test.cpp + tests/knn_test.cpp ) target_link_libraries(engine_tests PRIVATE engine GTest::gtest_main Arrow::arrow_shared Parquet::parquet_shared) gtest_discover_tests(engine_tests) diff --git a/engine/include/backend/engine/core/trajectory_db.hpp b/engine/include/backend/engine/core/trajectory_db.hpp index 8ed1c3f..88882a3 100644 --- a/engine/include/backend/engine/core/trajectory_db.hpp +++ b/engine/include/backend/engine/core/trajectory_db.hpp @@ -3,11 +3,20 @@ #include #include #include +#include #include #include namespace core { +// Smallest axis-aligned box around a trajectory's x/y. +struct BoundingBox { + double x_min = -std::numeric_limits::infinity(); + double x_max = std::numeric_limits::infinity(); + double y_min = -std::numeric_limits::infinity(); + double y_max = std::numeric_limits::infinity(); +}; + struct TrajectoryView { std::int32_t trajectory_id; std::span t; @@ -15,6 +24,7 @@ struct TrajectoryView { std::span lon; std::span x; std::span y; + BoundingBox bbox{}; std::size_t size() const { return t.size(); } }; @@ -32,8 +42,13 @@ class TrajectoryDb { const auto start = m_offsets[i]; const auto len = m_offsets[i + 1] - start; return TrajectoryView{ - m_trajectory_ids[i], {m_t.data() + start, len}, {m_lat.data() + start, len}, - {m_lon.data() + start, len}, {m_x.data() + start, len}, {m_y.data() + start, len}, + m_trajectory_ids[i], + {m_t.data() + start, len}, + {m_lat.data() + start, len}, + {m_lon.data() + start, len}, + {m_x.data() + start, len}, + {m_y.data() + start, len}, + m_boxes[i], }; } @@ -48,6 +63,7 @@ class TrajectoryDb { std::vector m_lon; std::vector m_x; std::vector m_y; + std::vector m_boxes; }; // Copies validated trajectories, including projected coordinates, in the given order. diff --git a/engine/include/backend/engine/query/knn.hpp b/engine/include/backend/engine/query/knn.hpp index 9669ce6..fe2da30 100644 --- a/engine/include/backend/engine/query/knn.hpp +++ b/engine/include/backend/engine/query/knn.hpp @@ -14,8 +14,8 @@ struct KnnQuery { }; struct KnnConfig { - std::size_t k = 5; - std::size_t query_count = 10; // 0 = use every trajectory in the query pool + std::size_t k = 10; + std::size_t query_count = 0; // 0 = use every trajectory in the query pool std::uint64_t seed = 42; }; diff --git a/engine/src/core/trajectory_db.cpp b/engine/src/core/trajectory_db.cpp index 05da944..3fd5ce3 100644 --- a/engine/src/core/trajectory_db.cpp +++ b/engine/src/core/trajectory_db.cpp @@ -1,5 +1,7 @@ #include "backend/engine/core/trajectory_db.hpp" #include "backend/engine/core/projection.hpp" +#include +#include #include #include #include @@ -37,6 +39,21 @@ TrajectoryDb::TrajectoryDb(std::vector offsets, xs[k] = p.x; ys[k] = p.y; } + + // For each trajectory it goes throguh all its points + // and keeps min/max of x and y coordinates seen + constexpr double inf = std::numeric_limits::infinity(); + m_boxes.resize(size()); + for (std::size_t i = 0; i < size(); ++i) { + BoundingBox box{inf, -inf, inf, -inf}; + for (auto k = m_offsets[i]; k < m_offsets[i + 1]; ++k) { + box.x_min = std::min(box.x_min, xs[k]); + box.x_max = std::max(box.x_max, xs[k]); + box.y_min = std::min(box.y_min, ys[k]); + box.y_max = std::max(box.y_max, ys[k]); + } + m_boxes[i] = box; + } } TrajectoryDb subset(const TrajectoryDb &trajectories, std::span indices) { @@ -55,10 +72,12 @@ TrajectoryDb subset(const TrajectoryDb &trajectories, std::span #include #include +#include #include namespace query { @@ -69,6 +70,46 @@ double squared_endpoint_lower_bound(const core::TrajectoryView &query, squared_point_dist(query.x[q_last], query.y[q_last], reference.x[r_last], reference.y[r_last])); } + +// Point distance to bounding rectangle of a given trajectory bounding box +double squared_dist_to_box(double x, double y, const core::BoundingBox &box) { + const double dx = std::max({box.x_min - x, 0.0, x - box.x_max}); + const double dy = std::max({box.y_min - y, 0.0, y - box.y_max}); + return dx * dx + dy * dy; +} + +double squared_box_gap_lower_bound(const core::TrajectoryView &query, + const core::TrajectoryView &reference) { + if (query.size() == 0 || reference.size() == 0) { + return std::numeric_limits::infinity(); + } + const core::BoundingBox &a = query.bbox; + const core::BoundingBox &b = reference.bbox; + const double dx = std::max({a.x_min - b.x_max, 0.0, b.x_min - a.x_max}); + const double dy = std::max({a.y_min - b.y_max, 0.0, b.y_min - a.y_max}); + return dx * dx + dy * dy; +} + +double squared_points_to_box_lower_bound(const core::TrajectoryView &query, + const core::TrajectoryView &reference) { + if (query.size() == 0 || reference.size() == 0) { + return std::numeric_limits::infinity(); + } + double bound = 0.0; + for (std::size_t i = 0; i < query.size(); ++i) { + bound = std::max(bound, squared_dist_to_box(query.x[i], query.y[i], reference.bbox)); + } + for (std::size_t j = 0; j < reference.size(); ++j) { + bound = std::max(bound, squared_dist_to_box(reference.x[j], reference.y[j], query.bbox)); + } + return bound; +} + +struct Candidate { + double squared_lower_bound; + std::size_t idx; +}; + } // namespace struct neighbor { @@ -82,22 +123,37 @@ struct neighbor { std::vector knn(const core::TrajectoryDb &trajectory_db, const core::TrajectoryView &query_trajectory, std::size_t k) { + // Lower bound on the Fréchet distance from the query to each trajectory, sorted ascending. + // Once a bound reaches the current k-th best distance, every remaining candidate is out too. + std::vector candidates(trajectory_db.size()); + for (std::size_t i = 0; i < trajectory_db.size(); ++i) { + const core::TrajectoryView reference = trajectory_db.trajectory(i); + candidates[i] = {std::max(squared_endpoint_lower_bound(query_trajectory, reference), + squared_box_gap_lower_bound(query_trajectory, reference)), + i}; + } + std::ranges::sort(candidates, [](const Candidate &a, const Candidate &b) { + return std::tie(a.squared_lower_bound, a.idx) < std::tie(b.squared_lower_bound, b.idx); + }); std::priority_queue curr_neighbors; std::vector prev; std::vector curr; - for (std::size_t i = 0; i < trajectory_db.size(); ++i) { - - core::TrajectoryView reference_trajectory = trajectory_db.trajectory(i); + for (const Candidate &candidate : candidates) { + const std::size_t i = candidate.idx; // Until the heap is full any distance qualifies, so there is no bound to stop early at. const double curr_max_dist = curr_neighbors.size() < k ? std::numeric_limits::infinity() : curr_neighbors.top().squared_frechet_distance; - // Even the best case for this trajectory cannot beat the current k-th neighbour. - // shortest leash can never be shorter than first or last point + if (candidate.squared_lower_bound >= curr_max_dist) { + break; + } + + core::TrajectoryView reference_trajectory = trajectory_db.trajectory(i); if (curr_neighbors.size() == k && - squared_endpoint_lower_bound(query_trajectory, reference_trajectory) >= curr_max_dist) { + squared_points_to_box_lower_bound(query_trajectory, reference_trajectory) >= + curr_max_dist) { continue; } double curr_dist = get_squared_frechet_distance(query_trajectory, reference_trajectory, diff --git a/engine/tests/knn_test.cpp b/engine/tests/knn_test.cpp new file mode 100644 index 0000000..95582af --- /dev/null +++ b/engine/tests/knn_test.cpp @@ -0,0 +1,125 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "backend/engine/core/trajectory_db.hpp" +#include "backend/engine/query/knn.hpp" + +namespace { + +using Track = std::vector>; // (lat, lon) per point + +core::TrajectoryDb make_db(const std::vector &tracks) { + std::vector offsets{0}; + std::vector ids; + std::vector t; + std::vector lat; + std::vector lon; + for (std::size_t i = 0; i < tracks.size(); ++i) { + ids.push_back(static_cast(i)); + for (std::size_t k = 0; k < tracks[i].size(); ++k) { + t.push_back(static_cast(k)); + lat.push_back(tracks[i][k].first); + lon.push_back(tracks[i][k].second); + } + offsets.push_back(t.size()); + } + return {std::move(offsets), std::move(ids), std::move(t), std::move(lat), std::move(lon)}; +} + +// Random walks starting anywhere in a ~20 km area, so some are near each other and most are not +std::vector random_tracks(std::mt19937_64 &rng, std::size_t count) { + std::uniform_real_distribution start_lat(39.8, 40.0); + std::uniform_real_distribution start_lon(116.2, 116.45); + std::uniform_int_distribution length(1, 60); + std::normal_distribution step(0.0, 0.002); + std::vector tracks(count); + for (auto &track : tracks) { + double la = start_lat(rng); + double lo = start_lon(rng); + const std::size_t n = length(rng); + for (std::size_t k = 0; k < n; ++k) { + track.emplace_back(la, lo); + la += step(rng); + lo += step(rng); + } + } + return tracks; +} + +double frechet(const core::TrajectoryView &a, const core::TrajectoryView &b) { + std::vector> dp(a.size(), std::vector(b.size())); + for (std::size_t i = 0; i < a.size(); ++i) { + for (std::size_t j = 0; j < b.size(); ++j) { + const double d = std::hypot(a.x[i] - b.x[j], a.y[i] - b.y[j]); + if (i == 0 && j == 0) + dp[i][j] = d; + else if (i == 0) + dp[i][j] = std::max(d, dp[i][j - 1]); + else if (j == 0) + dp[i][j] = std::max(d, dp[i - 1][j]); + else + dp[i][j] = std::max(d, std::min({dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]})); + } + } + return dp.back().back(); +} + +// Full Fréchet distance to every trajectory, no pruning +std::vector brute_force_knn(const core::TrajectoryDb &db, + const core::TrajectoryView &query, std::size_t k) { + std::vector> distances; + for (std::size_t i = 0; i < db.size(); ++i) { + distances.emplace_back(frechet(query, db.trajectory(i)), i); + } + std::ranges::sort(distances); + std::vector result; + for (std::size_t i = 0; i < std::min(k, distances.size()); ++i) { + result.push_back(distances[i].second); + } + return result; +} + +} // namespace + +TEST(Knn, FindsClosestTrajectoriesClosestFirst) { + const auto db = make_db({ + {{39.90, 116.30}, {39.91, 116.30}}, // far + {{39.950, 116.40}, {39.951, 116.40}}, + {{39.950, 116.40}, {39.952, 116.40}}, // exact match + {{39.953, 116.40}, {39.954, 116.40}}, + }); + const auto query = db.trajectory(2); + const std::vector expected = {2, 1, 3}; + EXPECT_EQ(query::knn(db, query, 3), expected); +} + +TEST(Knn, ZeroKReturnsNothing) { + const auto db = make_db({{{39.9, 116.3}, {39.91, 116.3}}}); + EXPECT_TRUE(query::knn(db, db.trajectory(0), 0).empty()); +} + +TEST(Knn, KLargerThanDatabaseReturnsEverything) { + std::mt19937_64 rng(3); + const auto db = make_db(random_tracks(rng, 4)); + const auto query = db.trajectory(1); + EXPECT_EQ(query::knn(db, query, 10), brute_force_knn(db, query, 10)); +} + +TEST(Knn, MatchesBruteForceOnRandomTrajectories) { + std::mt19937_64 rng(7); + const auto db = make_db(random_tracks(rng, 300)); + const auto queries = make_db(random_tracks(rng, 40)); + for (const std::size_t k : {1u, 3u, 5u, 10u}) { + for (std::size_t q = 0; q < queries.size(); ++q) { + const auto query = queries.trajectory(q); + EXPECT_EQ(query::knn(db, query, k), brute_force_knn(db, query, k)) + << "query " << q << ", k " << k; + } + } +} diff --git a/engine/tests/subset_test.cpp b/engine/tests/subset_test.cpp index bd0f210..03ecf5c 100644 --- a/engine/tests/subset_test.cpp +++ b/engine/tests/subset_test.cpp @@ -39,6 +39,10 @@ void expect_same_trajectory(const core::TrajectoryView &a, const core::Trajector EXPECT_TRUE(std::ranges::equal(a.lon, b.lon)); EXPECT_TRUE(std::ranges::equal(a.x, b.x)); EXPECT_TRUE(std::ranges::equal(a.y, b.y)); + EXPECT_EQ(a.bbox.x_min, b.bbox.x_min); + EXPECT_EQ(a.bbox.x_max, b.bbox.x_max); + EXPECT_EQ(a.bbox.y_min, b.bbox.y_min); + EXPECT_EQ(a.bbox.y_max, b.bbox.y_max); } } // namespace @@ -97,3 +101,14 @@ TEST(Subset, RejectsOutOfRangeIndices) { const std::vector indices{1}; EXPECT_THROW(core::subset(db, indices), std::out_of_range); } + +TEST(TrajectoryDb, BoundingBoxCoversExactlyTheTrajectorysPoints) { + const auto db = make_db({{0, 1, 2}, {5, 6}}); + for (std::size_t i = 0; i < db.size(); ++i) { + const auto trajectory = db.trajectory(i); + EXPECT_EQ(trajectory.bbox.x_min, std::ranges::min(trajectory.x)); + EXPECT_EQ(trajectory.bbox.x_max, std::ranges::max(trajectory.x)); + EXPECT_EQ(trajectory.bbox.y_min, std::ranges::min(trajectory.y)); + EXPECT_EQ(trajectory.bbox.y_max, std::ranges::max(trajectory.y)); + } +} From a889dc79317e3170d129c78bbe870cd2c7c2b481 Mon Sep 17 00:00:00 2001 From: Kristofferjoha Date: Sat, 24 Oct 2026 11:52:47 +0200 Subject: [PATCH 2/4] safeguard --- engine/include/backend/engine/query/range_query.hpp | 2 +- engine/src/query/knn.cpp | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/engine/include/backend/engine/query/range_query.hpp b/engine/include/backend/engine/query/range_query.hpp index 4c9887d..ea303d6 100644 --- a/engine/include/backend/engine/query/range_query.hpp +++ b/engine/include/backend/engine/query/range_query.hpp @@ -24,7 +24,7 @@ struct RangeQuery { }; struct RangeConfig { - std::size_t query_count = 500; + std::size_t query_count = 20000; double box_size_m = 2000.0; std::int64_t window_seconds = 604800; // 7 days std::size_t min_results = 5; diff --git a/engine/src/query/knn.cpp b/engine/src/query/knn.cpp index 73700d1..e836010 100644 --- a/engine/src/query/knn.cpp +++ b/engine/src/query/knn.cpp @@ -123,6 +123,10 @@ struct neighbor { std::vector knn(const core::TrajectoryDb &trajectory_db, const core::TrajectoryView &query_trajectory, std::size_t k) { + if (k == 0) { + return {}; + } + // Lower bound on the Fréchet distance from the query to each trajectory, sorted ascending. // Once a bound reaches the current k-th best distance, every remaining candidate is out too. std::vector candidates(trajectory_db.size()); From fc302313da41fea0d886a2fcc6e87d57158d2536 Mon Sep 17 00:00:00 2001 From: Kristofferjoha Date: Mon, 26 Oct 2026 19:02:05 +0100 Subject: [PATCH 3/4] format --- engine/include/backend/engine/core/trajectory_db.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/include/backend/engine/core/trajectory_db.hpp b/engine/include/backend/engine/core/trajectory_db.hpp index 88882a3..c35cff6 100644 --- a/engine/include/backend/engine/core/trajectory_db.hpp +++ b/engine/include/backend/engine/core/trajectory_db.hpp @@ -9,7 +9,7 @@ namespace core { -// Smallest axis-aligned box around a trajectory's x/y. +// Smallest axis-aligned box around a trajectory's x/y. struct BoundingBox { double x_min = -std::numeric_limits::infinity(); double x_max = std::numeric_limits::infinity(); From 803ae761a7561e8b4bb972b2c891c043dbf0f501 Mon Sep 17 00:00:00 2001 From: Kristofferjoha Date: Wed, 28 Oct 2026 09:40:34 +0100 Subject: [PATCH 4/4] d --- engine/configs/geolife.json | 6 ++--- .../include/backend/engine/evaluator/knn.hpp | 3 +++ engine/src/evaluator/knn.cpp | 2 ++ engine/src/pipeline/pipeline.cpp | 6 +++-- engine/src/query/knn.cpp | 6 ++--- engine/tests/evaluator_test.cpp | 23 +++++++++++++++++++ 6 files changed, 37 insertions(+), 9 deletions(-) diff --git a/engine/configs/geolife.json b/engine/configs/geolife.json index e550a0f..a6aa57b 100644 --- a/engine/configs/geolife.json +++ b/engine/configs/geolife.json @@ -1,5 +1,5 @@ { - "data_path": "output/geolife.parquet", + "data_path": "data/geolife.parquet", "seed": 42, "budget_shares": [0.0025, 0.005, 0.01, 0.02], "split": { @@ -8,8 +8,8 @@ }, "algorithms": ["RDP", "SED"], "knn": { - "k": 5, - "query_count": 10 + "k": 10, + "query_count": 0 }, "range": { "query_count": 500, diff --git a/engine/include/backend/engine/evaluator/knn.hpp b/engine/include/backend/engine/evaluator/knn.hpp index 981f38a..2c3664b 100644 --- a/engine/include/backend/engine/evaluator/knn.hpp +++ b/engine/include/backend/engine/evaluator/knn.hpp @@ -12,6 +12,8 @@ namespace evaluator { struct KnnScores { double kendalls_tau = std::numeric_limits::quiet_NaN(); double recall_at_k = std::numeric_limits::quiet_NaN(); + std::size_t query_count = 0; + std::size_t tau_query_count = 0; // queries with at least two shared neighbors }; // Each score is averaged over the queries where it is defined, NaN if it is defined for none. @@ -21,6 +23,7 @@ KnnScores evaluate_knn(const core::TrajectoryDb &compressed, const query::KnnWor namespace evaluator::knn { +// Rank agreement on shared neighbors only; NaN when fewer than two are shared. double kendalls_tau(const std::vector &original, const std::vector &simplified); diff --git a/engine/src/evaluator/knn.cpp b/engine/src/evaluator/knn.cpp index 85af645..2cd1a41 100644 --- a/engine/src/evaluator/knn.cpp +++ b/engine/src/evaluator/knn.cpp @@ -86,6 +86,8 @@ KnnScores evaluate_knn(const core::TrajectoryDb &compressed, const query::KnnWor } KnnScores scores; + scores.query_count = workload.entries.size(); + scores.tau_query_count = tau_scored; if (tau_scored != 0) scores.kendalls_tau = tau_sum / static_cast(tau_scored); if (recall_scored != 0) diff --git a/engine/src/pipeline/pipeline.cpp b/engine/src/pipeline/pipeline.cpp index f96a79c..a50befd 100644 --- a/engine/src/pipeline/pipeline.cpp +++ b/engine/src/pipeline/pipeline.cpp @@ -45,7 +45,8 @@ std::size_t total_budget(const core::TrajectoryDb &trajectories, double share) { } void print_scores(const evaluator::Scoretable &scores) { - std::cout << " knn tau: " << scores.knn.kendalls_tau + std::cout << " knn tau on shared neighbors: " << scores.knn.kendalls_tau << " (" + << scores.knn.tau_query_count << '/' << scores.knn.query_count << " queries)" << ", recall@k: " << scores.knn.recall_at_k << '\n'; std::cout << " where-at: within radius=" << scores.where_at.within_radius << ", average hit=" << scores.where_at.average_hit << '\n'; @@ -57,10 +58,11 @@ void print_scores(const evaluator::Scoretable &scores) { void run(const Config &config) { const auto [database, queries] = load_split(config); print_size("database", database); - print_size("queries", queries); + print_size("query pool", queries); const auto knn_workload = query::generate_knn_queries(database, queries, config.knn, config.seed); + std::cout << "knn: " << knn_workload.entries.size() << " queries, k=" << knn_workload.k << '\n'; const auto where_at = query::generate_where_at_queries(database, config.where_at, config.seed); std::cout << "where-at: " << where_at.queries.size() << " probes, " << where_at.skipped_trajectories << " trips without eligible time\n"; diff --git a/engine/src/query/knn.cpp b/engine/src/query/knn.cpp index a9ea8f0..6c8d414 100644 --- a/engine/src/query/knn.cpp +++ b/engine/src/query/knn.cpp @@ -205,10 +205,8 @@ std::vector knn(const core::TrajectoryDb &trajectory_db, double initial_cutoff = std::numeric_limits::infinity(); if (k <= candidates.size()) { - // Inspect a small pool, since candidates with nearby endpoints may still - // take very different routes. Any k feasible paths give a safe cutoff. - constexpr std::size_t seed_candidates = 64; - const auto count = std::min(candidates.size(), std::max(k, seed_candidates)); + constexpr std::size_t kInitialBoundCandidates = 64; + const auto count = std::min(candidates.size(), std::max(k, kInitialBoundCandidates)); std::priority_queue upper_bounds; for (std::size_t i = 0; i < count; ++i) { const double upper = squared_greedy_upper_bound( diff --git a/engine/tests/evaluator_test.cpp b/engine/tests/evaluator_test.cpp index 84080cb..8600451 100644 --- a/engine/tests/evaluator_test.cpp +++ b/engine/tests/evaluator_test.cpp @@ -100,8 +100,31 @@ TEST(Evaluator, CollectsKnnAndWhereAtScoresTogether) { const auto scores = evaluator.evaluate(); EXPECT_DOUBLE_EQ(scores.knn.kendalls_tau, 1); EXPECT_DOUBLE_EQ(scores.knn.recall_at_k, 1); + EXPECT_EQ(scores.knn.query_count, 2u); + EXPECT_EQ(scores.knn.tau_query_count, 2u); EXPECT_DOUBLE_EQ(scores.where_at.within_radius, 1); EXPECT_DOUBLE_EQ(scores.where_at.average_hit, 1); EXPECT_DOUBLE_EQ(evaluator.get_knn_scores().kendalls_tau, scores.knn.kendalls_tau); EXPECT_DOUBLE_EQ(evaluator.get_knn_scores().recall_at_k, scores.knn.recall_at_k); } + +TEST(KnnScores, ReportsCoverageWhenOnlySomeQueriesShareEnoughNeighbors) { + const core::TrajectoryDb db({0, 2, 4, 6, 8}, {10, 20, 30, 40}, {0, 10, 0, 10, 0, 10, 0, 10}, + std::vector(8, 39.95), + {116.35, 116.35, 116.36, 116.36, 116.37, 116.37, 116.38, 116.38}); + const query::KnnWorkload workload{db, {{0, {0, 1}}, {0, {0, 2}}, {0, {2, 3}}}, 2}; + const auto scores = evaluator::evaluate_knn(db, workload); + EXPECT_EQ(scores.query_count, 3u); + EXPECT_EQ(scores.tau_query_count, 1u); + EXPECT_DOUBLE_EQ(scores.kendalls_tau, 1.0); + EXPECT_DOUBLE_EQ(scores.recall_at_k, 0.5); +} + +TEST(KnnScores, EmptyWorkloadHasZeroCoverageAndUndefinedScores) { + const auto db = original(); + const auto scores = evaluator::evaluate_knn(db, {db, {}, 2}); + EXPECT_EQ(scores.query_count, 0u); + EXPECT_EQ(scores.tau_query_count, 0u); + EXPECT_TRUE(std::isnan(scores.kendalls_tau)); + EXPECT_TRUE(std::isnan(scores.recall_at_k)); +}