From 7c8f85def3faaea6a7fd842ee3053488fdbaa959 Mon Sep 17 00:00:00 2001 From: TB-PC Date: Wed, 4 Nov 2026 20:15:15 +0100 Subject: [PATCH 1/5] initital join query and eval --- engine/CMakeLists.txt | 3 + engine/configs/geolife.json | 4 + .../backend/engine/evaluator/evaluator.hpp | 5 + .../include/backend/engine/evaluator/join.hpp | 25 +++ .../backend/engine/pipeline/config.hpp | 2 + engine/include/backend/engine/query/join.hpp | 43 ++++ engine/include/backend/engine/query/knn.hpp | 12 ++ engine/src/evaluator/evaluator.cpp | 7 +- engine/src/evaluator/join.cpp | 73 +++++++ engine/src/pipeline/config.cpp | 10 +- engine/src/pipeline/pipeline.cpp | 14 +- engine/src/query/join.cpp | 91 ++++++++ engine/src/query/knn.cpp | 22 ++ engine/tests/config_test.cpp | 14 +- engine/tests/join_test.cpp | 202 ++++++++++++++++++ 15 files changed, 523 insertions(+), 4 deletions(-) create mode 100644 engine/include/backend/engine/evaluator/join.hpp create mode 100644 engine/include/backend/engine/query/join.hpp create mode 100644 engine/src/evaluator/join.cpp create mode 100644 engine/src/query/join.cpp create mode 100644 engine/tests/join_test.cpp diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index f09040a..511121d 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -32,6 +32,8 @@ add_library(engine src/evaluator/where_at.cpp src/query/range_query.cpp src/evaluator/range.cpp + src/query/join.cpp + src/evaluator/join.cpp ) target_include_directories(engine PUBLIC include) @@ -62,6 +64,7 @@ add_executable(engine_tests tests/parallel_test.cpp tests/knn_test.cpp tests/config_test.cpp + tests/join_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/configs/geolife.json b/engine/configs/geolife.json index cabd7c5..7f5a176 100644 --- a/engine/configs/geolife.json +++ b/engine/configs/geolife.json @@ -29,5 +29,9 @@ "hit_radius_m": 100.0, "min_tolerance_m": 25.0, "max_tolerance_m": 500.0 + }, + "join": { + "query_count": 1000, + "eps_neighbor": 10 } } diff --git a/engine/include/backend/engine/evaluator/evaluator.hpp b/engine/include/backend/engine/evaluator/evaluator.hpp index 72c4f2b..9d1c15b 100644 --- a/engine/include/backend/engine/evaluator/evaluator.hpp +++ b/engine/include/backend/engine/evaluator/evaluator.hpp @@ -1,8 +1,10 @@ #pragma once #include "backend/engine/core/trajectory_db.hpp" +#include "backend/engine/evaluator/join.hpp" #include "backend/engine/evaluator/knn.hpp" #include "backend/engine/evaluator/where_at.hpp" +#include "backend/engine/query/join.hpp" #include "backend/engine/query/knn.hpp" #include "backend/engine/query/range_query.hpp" @@ -15,6 +17,7 @@ struct Workloads { const query::KnnWorkload *knn = nullptr; // a pointer, as it also carries k and the query db std::span where_at = {}; std::span range = {}; + const query::JoinWorkload *join = nullptr; // a pointer, as it also carries eps and the queries }; // Kendall's tau is in [-1, 1] and the others in [0, 1], or NaN if it could not be computed @@ -22,6 +25,7 @@ struct Scoretable { KnnScores knn; double range_recall; WhereAtScores where_at; + JoinScores join; }; struct EvaluationResult { @@ -41,6 +45,7 @@ class Evaluator { KnnScores get_knn_scores() const; double get_range_recall() const; WhereAtScores get_where_at_scores() const; + JoinScores get_join_scores() const; private: const core::TrajectoryDb &m_simplified; diff --git a/engine/include/backend/engine/evaluator/join.hpp b/engine/include/backend/engine/evaluator/join.hpp new file mode 100644 index 0000000..568ef6d --- /dev/null +++ b/engine/include/backend/engine/evaluator/join.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "backend/engine/core/trajectory_db.hpp" +#include "backend/engine/query/join.hpp" + +#include +#include + +namespace evaluator { + +// Averages over queries. Precision skips queries with an empty simplified result, recall those +// with an empty reference, F1 those where both are empty. NaN if no query counts. +struct JoinScores { + double f1 = std::numeric_limits::quiet_NaN(); + double precision = std::numeric_limits::quiet_NaN(); + double recall = std::numeric_limits::quiet_NaN(); + double eps_m = std::numeric_limits::quiet_NaN(); + std::size_t query_count = 0; +}; + +// Runs each join query on compressed, which must hold the same trajectories in the same order +// as the database the workload was generated on +JoinScores evaluate_join(const core::TrajectoryDb &compressed, const query::JoinWorkload &workload); + +} // namespace evaluator diff --git a/engine/include/backend/engine/pipeline/config.hpp b/engine/include/backend/engine/pipeline/config.hpp index 7b7c1c6..dff0a74 100644 --- a/engine/include/backend/engine/pipeline/config.hpp +++ b/engine/include/backend/engine/pipeline/config.hpp @@ -1,6 +1,7 @@ #pragma once #include "backend/engine/evaluator/where_at.hpp" +#include "backend/engine/query/join.hpp" #include "backend/engine/query/knn.hpp" #include "backend/engine/query/range_query.hpp" #include "backend/engine/query/where_at_query.hpp" @@ -26,6 +27,7 @@ struct Config { query::RangeConfig range; query::WhereAtConfig where_at; evaluator::WhereAtScoringConfig where_at_scoring; + query::JoinConfig join; }; // Reads a JSON config. Only data_path is required, a missing key keeps its default. diff --git a/engine/include/backend/engine/query/join.hpp b/engine/include/backend/engine/query/join.hpp new file mode 100644 index 0000000..1021837 --- /dev/null +++ b/engine/include/backend/engine/query/join.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include "backend/engine/core/trajectory_db.hpp" + +#include +#include +#include + +namespace query { + +struct JoinConfig { + std::size_t query_count = 10; // 0 = use every trajectory in the query pool + double eps_m = 0.0; // join threshold in metres, 0 = derive it from the data + // With eps_m = 0, eps is the median over the queries of the Fréchet distance to their + // eps_neighbor-th nearest database trajectory, so a typical query has that many matches + std::size_t eps_neighbor = 10; +}; + +struct JoinQuery { + std::size_t query_index; // index into JoinWorkload::queries + std::vector reference; // database trajectories within eps, sorted + std::vector candidates; // within eps by the endpoint bound, sorted +}; + +// Similarity join between full-resolution query trajectories and the database: every database +// trajectory within discrete Fréchet distance eps of a query +struct JoinWorkload { + core::TrajectoryDb queries; + std::vector entries; + double eps_m = 0.0; +}; + +// Samples query_count trajectories from query_pool (the held-out split) and stores their join +// result in original as the reference answer +JoinWorkload generate_join_queries(const core::TrajectoryDb &original, + const core::TrajectoryDb &query_pool, + const JoinConfig &config = {}, std::uint64_t seed = 42); + +// Indices into trajectories of those within discrete Fréchet distance eps of query, sorted +std::vector join(const core::TrajectoryDb &trajectories, + const core::TrajectoryView &query, double eps); + +} // namespace query diff --git a/engine/include/backend/engine/query/knn.hpp b/engine/include/backend/engine/query/knn.hpp index 87554ad..5e68f25 100644 --- a/engine/include/backend/engine/query/knn.hpp +++ b/engine/include/backend/engine/query/knn.hpp @@ -37,4 +37,16 @@ KnnWorkload generate_knn_queries(const core::TrajectoryDb &original, std::vector knn(const core::TrajectoryDb &trajectories, const core::TrajectoryView &query, std::size_t k); +// Discrete Fréchet distance in metres, infinity if either trajectory is empty +double frechet_distance(const core::TrajectoryView &a, const core::TrajectoryView &b); + +// Whether the discrete Fréchet distance is at most max_dist, up to a relative 1e-12 for +// rounding. Stops as soon as it is not. +bool frechet_within(const core::TrajectoryView &a, const core::TrajectoryView &b, + double max_dist); + +// Lower bound on the discrete Fréchet distance from the endpoints, which every simplification +// keeps, so it is the same for a trajectory and any of its simplifications +double endpoint_lower_bound(const core::TrajectoryView &a, const core::TrajectoryView &b); + } // namespace query diff --git a/engine/src/evaluator/evaluator.cpp b/engine/src/evaluator/evaluator.cpp index c1ed296..e34f47d 100644 --- a/engine/src/evaluator/evaluator.cpp +++ b/engine/src/evaluator/evaluator.cpp @@ -25,7 +25,8 @@ EvaluationResult Evaluator::evaluate() const { auto range = evaluate_range(m_simplified, m_workloads.range); return {.scores = {.knn = knn.scores, .range_recall = range.recall, - .where_at = get_where_at_scores()}, + .where_at = get_where_at_scores(), + .join = get_join_scores()}, .knn = std::move(knn.answers), .range = std::move(range.answers)}; } @@ -42,4 +43,8 @@ WhereAtScores Evaluator::get_where_at_scores() const { return evaluate_where_at(m_simplified, m_workloads.where_at, m_where_at_config); } +JoinScores Evaluator::get_join_scores() const { + return m_workloads.join ? evaluate_join(m_simplified, *m_workloads.join) : JoinScores{}; +} + } // namespace evaluator diff --git a/engine/src/evaluator/join.cpp b/engine/src/evaluator/join.cpp new file mode 100644 index 0000000..30c57ce --- /dev/null +++ b/engine/src/evaluator/join.cpp @@ -0,0 +1,73 @@ +#include "backend/engine/evaluator/join.hpp" +#include "backend/engine/core/parallel.hpp" +#include "backend/engine/query/knn.hpp" + +#include +#include +#include +#include +#include + +namespace evaluator { + +namespace { + +struct Counts { + std::size_t found = 0; + std::size_t hits = 0; +}; + +double average(const std::vector &values) { + double sum = 0.0; + std::size_t counted = 0; + for (const double value : values) { + if (!std::isnan(value)) { + sum += value; + ++counted; + } + } + return counted == 0 ? std::numeric_limits::quiet_NaN() + : sum / static_cast(counted); +} + +} // namespace + +JoinScores evaluate_join(const core::TrajectoryDb &compressed, + const query::JoinWorkload &workload) { + const std::size_t n = workload.entries.size(); + const double nan = std::numeric_limits::quiet_NaN(); + std::vector f1(n, nan), precision(n, nan), recall(n, nan); + + core::parallel_for(n, [&](std::size_t i) { + const auto &entry = workload.entries[i]; + const auto query = workload.queries.trajectory(entry.query_index); + + // Simplification keeps endpoints, so the original candidates cover every possible match + Counts counts; + for (const auto j : entry.candidates) { + if (!query::frechet_within(query, compressed.trajectory(j), workload.eps_m)) + continue; + ++counts.found; + if (std::ranges::binary_search(entry.reference, j)) + ++counts.hits; + } + + const auto hits = static_cast(counts.hits); + const auto found = static_cast(counts.found); + const auto truth = static_cast(entry.reference.size()); + if (counts.found != 0) + precision[i] = hits / found; + if (!entry.reference.empty()) + recall[i] = hits / truth; + if (counts.found != 0 || !entry.reference.empty()) + f1[i] = 2.0 * hits / (found + truth); + }); + + return {.f1 = average(f1), + .precision = average(precision), + .recall = average(recall), + .eps_m = workload.eps_m, + .query_count = n}; +} + +} // namespace evaluator diff --git a/engine/src/pipeline/config.cpp b/engine/src/pipeline/config.cpp index ab667c3..0ac9abe 100644 --- a/engine/src/pipeline/config.cpp +++ b/engine/src/pipeline/config.cpp @@ -84,10 +84,17 @@ void parse_where_at_scoring_config(Config &config, const json &scoring) { scoring.value("max_tolerance_m", config.where_at_scoring.max_tolerance_m); } +void parse_join_config(Config &config, const json &join) { + validate_keys(join, {"query_count", "eps_m", "eps_neighbor"}, "join"); + config.join.query_count = join.value("query_count", config.join.query_count); + config.join.eps_m = join.value("eps_m", config.join.eps_m); + config.join.eps_neighbor = join.value("eps_neighbor", config.join.eps_neighbor); +} + Config parse_config(const json &data) { validate_keys(data, {"data_path", "export_results", "export_dir", "seed", "budget_shares", - "algorithms", "split", "knn", "range", "where_at", "where_at_scoring"}, + "algorithms", "split", "knn", "range", "where_at", "where_at_scoring", "join"}, "root"); Config config; @@ -117,6 +124,7 @@ Config parse_config(const json &data) { parse_range_config(config, data.value("range", json::object())); parse_where_at_config(config, data.value("where_at", json::object())); parse_where_at_scoring_config(config, data.value("where_at_scoring", json::object())); + parse_join_config(config, data.value("join", json::object())); return config; } diff --git a/engine/src/pipeline/pipeline.cpp b/engine/src/pipeline/pipeline.cpp index 197e44a..6395f4d 100644 --- a/engine/src/pipeline/pipeline.cpp +++ b/engine/src/pipeline/pipeline.cpp @@ -5,6 +5,7 @@ #include "backend/engine/evaluator/evaluator.hpp" #include "backend/engine/io/parquet_loader.hpp" #include "backend/engine/io/workload_export.hpp" +#include "backend/engine/query/join.hpp" #include "backend/engine/query/knn.hpp" #include "backend/engine/query/range_query.hpp" #include "backend/engine/query/where_at_query.hpp" @@ -54,6 +55,8 @@ void print_scores(const evaluator::Scoretable &scores) { std::cout << " where-at: within radius=" << scores.where_at.within_radius << ", average hit=" << scores.where_at.average_hit << '\n'; std::cout << " range recall: " << scores.range_recall << '\n'; + std::cout << " join F1: " << scores.join.f1 << ", precision: " << scores.join.precision + << ", recall: " << scores.join.recall << '\n'; } } // namespace @@ -71,6 +74,12 @@ void run(const Config &config) { << where_at.skipped_trajectories << " trips without eligible time\n"; const auto range = query::generate_range_queries(database, config.range, config.seed); std::cout << "range: " << range.queries.size() << " queries\n"; + const auto join = query::generate_join_queries(database, queries, config.join, config.seed); + std::size_t join_pairs = 0; + for (const auto &entry : join.entries) + join_pairs += entry.reference.size(); + std::cout << "join: " << join.entries.size() << " queries, eps=" << join.eps_m << " m, " + << join_pairs << " matching pairs\n"; if (config.export_results) { io::export_workloads(config.export_dir, database, range.queries, knn_workload); @@ -89,7 +98,10 @@ void run(const Config &config) { const evaluator::Evaluator evaluator( database, compressed, - {.knn = &knn_workload, .where_at = where_at.queries, .range = range.queries}, + {.knn = &knn_workload, + .where_at = where_at.queries, + .range = range.queries, + .join = &join}, config.where_at_scoring); const auto evaluation = evaluator.evaluate(); print_scores(evaluation.scores); diff --git a/engine/src/query/join.cpp b/engine/src/query/join.cpp new file mode 100644 index 0000000..06bfb70 --- /dev/null +++ b/engine/src/query/join.cpp @@ -0,0 +1,91 @@ +#include "backend/engine/query/join.hpp" +#include "backend/engine/core/parallel.hpp" +#include "backend/engine/query/knn.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace query { + +namespace { + +std::vector endpoint_candidates(const core::TrajectoryDb &trajectories, + const core::TrajectoryView &query, double eps) { + std::vector candidates; + for (std::size_t i = 0; i < trajectories.size(); ++i) { + if (endpoint_lower_bound(query, trajectories.trajectory(i)) <= eps) + candidates.push_back(i); + } + return candidates; +} + +double median(std::vector values) { + const auto middle = values.begin() + static_cast(values.size() / 2); + std::ranges::nth_element(values, middle); + return *middle; +} + +} // namespace + +std::vector join(const core::TrajectoryDb &trajectories, + const core::TrajectoryView &query, double eps) { + std::vector result; + for (const auto i : endpoint_candidates(trajectories, query, eps)) { + if (frechet_within(query, trajectories.trajectory(i), eps)) + result.push_back(i); + } + return result; +} + +JoinWorkload generate_join_queries(const core::TrajectoryDb &original, + const core::TrajectoryDb &query_pool, const JoinConfig &config, + std::uint64_t seed) { + if (!std::isfinite(config.eps_m) || config.eps_m < 0.0) + throw std::invalid_argument("join: eps_m must be finite and non-negative"); + if (config.eps_m == 0.0 && config.eps_neighbor == 0) + throw std::invalid_argument("join: eps_neighbor must be positive when eps_m is 0"); + + std::vector picked(query_pool.size()); + std::iota(picked.begin(), picked.end(), 0); + if (config.query_count != 0 && config.query_count < picked.size()) { + std::mt19937_64 rng(seed); + std::ranges::shuffle(picked, rng); + picked.resize(config.query_count); + } + + JoinWorkload results{core::subset(query_pool, picked), {}, config.eps_m}; + const std::size_t n = results.queries.size(); + if (n == 0 || original.size() == 0) + return results; + + if (results.eps_m == 0.0) { + const std::size_t rank = std::min(config.eps_neighbor, original.size()); + std::vector distances(n); + core::parallel_for(n, [&](std::size_t i) { + const auto query = results.queries.trajectory(i); + const auto neighbors = knn(original, query, rank); + distances[i] = frechet_distance(query, original.trajectory(neighbors.back())); + }); + results.eps_m = median(std::move(distances)); + } + + results.entries.resize(n); + core::parallel_for(n, [&](std::size_t i) { + const auto query = results.queries.trajectory(i); + auto &entry = results.entries[i]; + entry.query_index = i; + entry.candidates = endpoint_candidates(original, query, results.eps_m); + for (const auto j : entry.candidates) { + if (frechet_within(query, original.trajectory(j), results.eps_m)) + entry.reference.push_back(j); + } + }); + return results; +} + +} // namespace query diff --git a/engine/src/query/knn.cpp b/engine/src/query/knn.cpp index 07dbfbb..c452443 100644 --- a/engine/src/query/knn.cpp +++ b/engine/src/query/knn.cpp @@ -206,6 +206,28 @@ struct neighbor { } }; +double frechet_distance(const core::TrajectoryView &a, const core::TrajectoryView &b) { + std::vector prev; + std::vector curr; + return std::sqrt(get_squared_frechet_distance(a, b, std::numeric_limits::infinity(), + prev, curr)); +} + +bool frechet_within(const core::TrajectoryView &a, const core::TrajectoryView &b, + double max_dist) { + // Values below the cutoff are exact, so anything at most max_dist^2 is found. The relative + // slack absorbs rounding when max_dist is itself a distance, as sqrt(d)^2 can fall below d. + const double cutoff = std::nextafter(max_dist * max_dist * (1.0 + 1e-12), + std::numeric_limits::infinity()); + std::vector prev; + std::vector curr; + return get_squared_frechet_distance(a, b, cutoff, prev, curr) < cutoff; +} + +double endpoint_lower_bound(const core::TrajectoryView &a, const core::TrajectoryView &b) { + return std::sqrt(squared_endpoint_lower_bound(a, b)); +} + std::vector knn(const core::TrajectoryDb &trajectory_db, const core::TrajectoryView &query_trajectory, std::size_t k) { if (k == 0) { diff --git a/engine/tests/config_test.cpp b/engine/tests/config_test.cpp index 1aaf259..9542a31 100644 --- a/engine/tests/config_test.cpp +++ b/engine/tests/config_test.cpp @@ -32,6 +32,18 @@ TEST(Config, MissingKeysKeepDefaults) { EXPECT_EQ(config.range.query_count, defaults.range.query_count); EXPECT_EQ(config.where_at.probes_per_trajectory, defaults.where_at.probes_per_trajectory); EXPECT_DOUBLE_EQ(config.where_at_scoring.hit_radius_m, defaults.where_at_scoring.hit_radius_m); + EXPECT_EQ(config.join.query_count, defaults.join.query_count); + EXPECT_DOUBLE_EQ(config.join.eps_m, defaults.join.eps_m); +} + +TEST(Config, ReadsJoinKeys) { + const auto config = pipeline::load_config(write_config("join.json", R"({ + "data_path": "data.parquet", + "join": {"query_count": 0, "eps_m": 250, "eps_neighbor": 3} + })")); + EXPECT_EQ(config.join.query_count, 0); + EXPECT_DOUBLE_EQ(config.join.eps_m, 250); + EXPECT_EQ(config.join.eps_neighbor, 3); } TEST(Config, ReadsTopLevelAndNestedKeys) { @@ -111,7 +123,7 @@ TEST(Config, RejectsUnknownKeysAndNestedSeeds) { EXPECT_THROW( pipeline::load_config(write_config("unknown_key.json", R"({"data_path": "d", "sead": 7})")), std::invalid_argument); - for (const auto §ion : {"split", "knn", "range", "where_at"}) { + for (const auto §ion : {"split", "knn", "range", "where_at", "join"}) { const auto contents = std::string(R"({"data_path": "d", ")") + section + R"(": {"seed": 7}})"; EXPECT_THROW(pipeline::load_config(write_config("nested_seed.json", contents)), diff --git a/engine/tests/join_test.cpp b/engine/tests/join_test.cpp new file mode 100644 index 0000000..150e1ef --- /dev/null +++ b/engine/tests/join_test.cpp @@ -0,0 +1,202 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "backend/engine/core/simplification.hpp" +#include "backend/engine/core/trajectory_db.hpp" +#include "backend/engine/evaluator/join.hpp" +#include "backend/engine/query/join.hpp" +#include "backend/engine/query/knn.hpp" +#include "backend/engine/selection/selector.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 in a small area, so many are within a few hundred metres of each other +std::vector random_tracks(std::mt19937_64 &rng, std::size_t count) { + std::uniform_real_distribution start_lat(39.90, 39.92); + std::uniform_real_distribution start_lon(116.30, 116.32); + std::uniform_int_distribution length(2, 40); + std::normal_distribution step(0.0, 0.0005); + 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(); +} + +} // namespace + +TEST(Join, FrechetHelpersMatchBruteForce) { + std::mt19937_64 rng(5); + const auto db = make_db(random_tracks(rng, 60)); + for (std::size_t a = 0; a < db.size(); a += 7) { + for (std::size_t b = 0; b < db.size(); ++b) { + const double expected = frechet(db.trajectory(a), db.trajectory(b)); + EXPECT_NEAR(query::frechet_distance(db.trajectory(a), db.trajectory(b)), expected, + 1e-9); + EXPECT_TRUE(query::frechet_within(db.trajectory(a), db.trajectory(b), expected)); + if (expected > 0.0) { + EXPECT_FALSE( + query::frechet_within(db.trajectory(a), db.trajectory(b), expected * 0.999)); + } + EXPECT_LE(query::endpoint_lower_bound(db.trajectory(a), db.trajectory(b)), + expected + 1e-9); + } + } +} + +TEST(Join, MatchesBruteForce) { + std::mt19937_64 rng(11); + const auto db = make_db(random_tracks(rng, 200)); + const auto queries = make_db(random_tracks(rng, 20)); + for (const double eps : {100.0, 300.0, 800.0}) { + for (std::size_t q = 0; q < queries.size(); ++q) { + std::vector expected; + for (std::size_t i = 0; i < db.size(); ++i) { + if (frechet(queries.trajectory(q), db.trajectory(i)) <= eps) + expected.push_back(i); + } + EXPECT_EQ(query::join(db, queries.trajectory(q), eps), expected) + << "query " << q << ", eps " << eps; + } + } +} + +TEST(Join, WorkloadDerivesEpsFromNeighbourDistance) { + std::mt19937_64 rng(13); + const auto db = make_db(random_tracks(rng, 150)); + const auto pool = make_db(random_tracks(rng, 30)); + const auto workload = + query::generate_join_queries(db, pool, {.query_count = 0, .eps_neighbor = 5}); + ASSERT_EQ(workload.entries.size(), pool.size()); + EXPECT_GT(workload.eps_m, 0.0); + + // eps is the median distance to the 5th neighbour, so half the queries have at least 5 matches + std::size_t with_five = 0; + for (const auto &entry : workload.entries) { + const auto query = workload.queries.trajectory(entry.query_index); + EXPECT_EQ(entry.reference, query::join(db, query, workload.eps_m)); + EXPECT_TRUE(std::ranges::includes(entry.candidates, entry.reference)); + with_five += entry.reference.size() >= 5; + } + EXPECT_GE(with_five, pool.size() / 2); +} + +TEST(Join, FixedEpsIsUsedAsGiven) { + std::mt19937_64 rng(17); + const auto db = make_db(random_tracks(rng, 50)); + const auto pool = make_db(random_tracks(rng, 10)); + const auto workload = + query::generate_join_queries(db, pool, {.query_count = 4, .eps_m = 400.0}); + EXPECT_EQ(workload.entries.size(), 4u); + EXPECT_DOUBLE_EQ(workload.eps_m, 400.0); +} + +TEST(Join, RejectsInvalidConfig) { + const auto db = make_db({{{39.9, 116.3}, {39.91, 116.3}}}); + EXPECT_THROW(query::generate_join_queries(db, db, {.eps_m = -1.0}), std::invalid_argument); + EXPECT_THROW(query::generate_join_queries(db, db, {.eps_m = 0.0, .eps_neighbor = 0}), + std::invalid_argument); +} + +TEST(JoinEvaluator, UnsimplifiedDatabaseScoresPerfectly) { + std::mt19937_64 rng(19); + const auto db = make_db(random_tracks(rng, 120)); + const auto pool = make_db(random_tracks(rng, 25)); + const auto workload = query::generate_join_queries(db, pool, {.query_count = 0}); + const auto scores = evaluator::evaluate_join(db, workload); + EXPECT_DOUBLE_EQ(scores.f1, 1.0); + EXPECT_DOUBLE_EQ(scores.precision, 1.0); + EXPECT_DOUBLE_EQ(scores.recall, 1.0); + EXPECT_EQ(scores.query_count, pool.size()); +} + +TEST(JoinEvaluator, SimplifiedScoresMatchBruteForceJoin) { + std::mt19937_64 rng(23); + const auto db = make_db(random_tracks(rng, 150)); + const auto pool = make_db(random_tracks(rng, 20)); + const auto workload = query::generate_join_queries(db, pool, {.query_count = 0}); + const std::vector budgets(db.size(), 3); + const auto compressed = core::materialize( + db, selection::simplify_database(db, selection::AlgorithmType::SED, budgets)); + + // F1 per query against a join run on the whole simplified database, averaged by hand + double f1_sum = 0.0; + std::size_t counted = 0; + for (const auto &entry : workload.entries) { + const auto found = + query::join(compressed, workload.queries.trajectory(entry.query_index), workload.eps_m); + std::vector hits; + std::ranges::set_intersection(entry.reference, found, std::back_inserter(hits)); + if (found.empty() && entry.reference.empty()) + continue; + f1_sum += 2.0 * static_cast(hits.size()) / + static_cast(found.size() + entry.reference.size()); + ++counted; + } + const auto scores = evaluator::evaluate_join(compressed, workload); + ASSERT_GT(counted, 0u); + EXPECT_NEAR(scores.f1, f1_sum / static_cast(counted), 1e-12); + EXPECT_LT(scores.f1, 1.0); +} + +TEST(JoinEvaluator, EmptyWorkloadIsNaN) { + const auto db = make_db({{{39.9, 116.3}, {39.91, 116.3}}}); + const query::JoinWorkload workload{core::subset(db, {}), {}, 100.0}; + const auto scores = evaluator::evaluate_join(db, workload); + EXPECT_TRUE(std::isnan(scores.f1)); + EXPECT_EQ(scores.query_count, 0u); +} From c3f32042ddd8b66763f5fe884eacfc0491b2bc8b Mon Sep 17 00:00:00 2001 From: TB-PC Date: Wed, 4 Nov 2026 20:37:04 +0100 Subject: [PATCH 2/5] moved helper functions to seperate file --- engine/CMakeLists.txt | 1 + .../include/backend/engine/evaluator/join.hpp | 3 +- .../backend/engine/query/frechet_helper.hpp | 38 ++++++ engine/include/backend/engine/query/join.hpp | 11 +- engine/include/backend/engine/query/knn.hpp | 12 -- engine/src/evaluator/join.cpp | 2 +- engine/src/query/frechet_helper.cpp | 125 ++++++++++++++++++ engine/src/query/join.cpp | 1 + engine/src/query/knn.cpp | 122 +---------------- engine/tests/join_test.cpp | 2 +- 10 files changed, 174 insertions(+), 143 deletions(-) create mode 100644 engine/include/backend/engine/query/frechet_helper.hpp create mode 100644 engine/src/query/frechet_helper.cpp diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 511121d..e46bdf7 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -29,6 +29,7 @@ add_library(engine src/evaluator/evaluator.cpp src/evaluator/knn.cpp src/query/knn.cpp + src/query/frechet_helper.cpp src/evaluator/where_at.cpp src/query/range_query.cpp src/evaluator/range.cpp diff --git a/engine/include/backend/engine/evaluator/join.hpp b/engine/include/backend/engine/evaluator/join.hpp index 568ef6d..82f3fbc 100644 --- a/engine/include/backend/engine/evaluator/join.hpp +++ b/engine/include/backend/engine/evaluator/join.hpp @@ -18,8 +18,7 @@ struct JoinScores { std::size_t query_count = 0; }; -// Runs each join query on compressed, which must hold the same trajectories in the same order -// as the database the workload was generated on + JoinScores evaluate_join(const core::TrajectoryDb &compressed, const query::JoinWorkload &workload); } // namespace evaluator diff --git a/engine/include/backend/engine/query/frechet_helper.hpp b/engine/include/backend/engine/query/frechet_helper.hpp new file mode 100644 index 0000000..2657e45 --- /dev/null +++ b/engine/include/backend/engine/query/frechet_helper.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "backend/engine/core/trajectory_db.hpp" + +#include + +namespace query { + +// Squared instead of std::hypot, which is slow. Fréchet only takes max and min of distances, +// Square still has order +inline double squared_point_dist(double a_x, double a_y, double b_x, double b_y) { + const double dx = a_x - b_x; + const double dy = a_y - b_y; + return dx * dx + dy * dy; +} + +// Squared discrete Fréchet distance. Values below max_squared_dist are exact, anything else is +// at or above it. +double get_squared_frechet_distance(const core::TrajectoryView &query, + const core::TrajectoryView &reference, double max_squared_dist, + std::vector &prev, std::vector &curr); + + +double squared_endpoint_lower_bound(const core::TrajectoryView &query, + const core::TrajectoryView &reference); + + +double frechet_distance(const core::TrajectoryView &a, const core::TrajectoryView &b); + +// Whether the discrete Fréchet distance is at most max_dist, up to a relative 1e-12 for +// rounding. Stops as soon as it is not. +bool frechet_within(const core::TrajectoryView &a, const core::TrajectoryView &b, + double max_dist); + + +double endpoint_lower_bound(const core::TrajectoryView &a, const core::TrajectoryView &b); + +} // namespace query diff --git a/engine/include/backend/engine/query/join.hpp b/engine/include/backend/engine/query/join.hpp index 1021837..2a40c37 100644 --- a/engine/include/backend/engine/query/join.hpp +++ b/engine/include/backend/engine/query/join.hpp @@ -17,13 +17,12 @@ struct JoinConfig { }; struct JoinQuery { - std::size_t query_index; // index into JoinWorkload::queries - std::vector reference; // database trajectories within eps, sorted - std::vector candidates; // within eps by the endpoint bound, sorted + std::size_t query_index; + std::vector reference; + std::vector candidates; }; -// Similarity join between full-resolution query trajectories and the database: every database -// trajectory within discrete Fréchet distance eps of a query + struct JoinWorkload { core::TrajectoryDb queries; std::vector entries; @@ -36,7 +35,7 @@ JoinWorkload generate_join_queries(const core::TrajectoryDb &original, const core::TrajectoryDb &query_pool, const JoinConfig &config = {}, std::uint64_t seed = 42); -// Indices into trajectories of those within discrete Fréchet distance eps of query, sorted + std::vector join(const core::TrajectoryDb &trajectories, const core::TrajectoryView &query, double eps); diff --git a/engine/include/backend/engine/query/knn.hpp b/engine/include/backend/engine/query/knn.hpp index 5e68f25..87554ad 100644 --- a/engine/include/backend/engine/query/knn.hpp +++ b/engine/include/backend/engine/query/knn.hpp @@ -37,16 +37,4 @@ KnnWorkload generate_knn_queries(const core::TrajectoryDb &original, std::vector knn(const core::TrajectoryDb &trajectories, const core::TrajectoryView &query, std::size_t k); -// Discrete Fréchet distance in metres, infinity if either trajectory is empty -double frechet_distance(const core::TrajectoryView &a, const core::TrajectoryView &b); - -// Whether the discrete Fréchet distance is at most max_dist, up to a relative 1e-12 for -// rounding. Stops as soon as it is not. -bool frechet_within(const core::TrajectoryView &a, const core::TrajectoryView &b, - double max_dist); - -// Lower bound on the discrete Fréchet distance from the endpoints, which every simplification -// keeps, so it is the same for a trajectory and any of its simplifications -double endpoint_lower_bound(const core::TrajectoryView &a, const core::TrajectoryView &b); - } // namespace query diff --git a/engine/src/evaluator/join.cpp b/engine/src/evaluator/join.cpp index 30c57ce..13648b9 100644 --- a/engine/src/evaluator/join.cpp +++ b/engine/src/evaluator/join.cpp @@ -1,6 +1,6 @@ #include "backend/engine/evaluator/join.hpp" #include "backend/engine/core/parallel.hpp" -#include "backend/engine/query/knn.hpp" +#include "backend/engine/query/frechet_helper.hpp" #include #include diff --git a/engine/src/query/frechet_helper.cpp b/engine/src/query/frechet_helper.cpp new file mode 100644 index 0000000..df5eff7 --- /dev/null +++ b/engine/src/query/frechet_helper.cpp @@ -0,0 +1,125 @@ +#include "backend/engine/query/frechet_helper.hpp" + +#include +#include +#include +#include +#include +#include + +namespace query { + +double get_squared_frechet_distance(const core::TrajectoryView &query, + const core::TrajectoryView &reference, double max_squared_dist, + std::vector &prev, std::vector &curr) { + const std::size_t n = query.size(); + const std::size_t m = reference.size(); + if (n == 0 || m == 0) { + return std::numeric_limits::infinity(); + } + + prev.resize(m); + curr.resize(m); + const double inf = std::numeric_limits::infinity(); + + // Only cells below the current k-th distance can improve the answer. Track the + // reachable span in each row instead of evaluating the entire n-by-m grid. + double value = 0.0; + std::size_t last = 0; + for (std::size_t j = 0; j < m; ++j) { + value = std::max( + value, squared_point_dist(query.x[0], query.y[0], reference.x[j], reference.y[j])); + if (value >= max_squared_dist) + break; + prev[j] = value; + last = j + 1; + } + if (last == 0) + return inf; + + // Cells at or above the cutoff keep their value instead of becoming infinity. They can only + // lead to values at or above the cutoff, so every value below it stays exact. + std::size_t first = 0; + for (std::size_t i = 1; i < n; ++i) { + const double qx = query.x[i]; + const double qy = query.y[i]; + const auto dist = [&](std::size_t j) { + return squared_point_dist(qx, qy, reference.x[j], reference.y[j]); + }; + std::size_t next_first = m; + std::size_t next_last = 0; + + // Within the previous row's reachable span [first, last), the cell above always exists, + // and the diagonal does for every cell but the first + double left = std::max(prev[first], dist(first)); + curr[first] = left; + if (left < max_squared_dist) { + next_first = first; + next_last = first + 1; + } + for (std::size_t j = first + 1; j < last; ++j) { + left = std::max(std::min({prev[j], prev[j - 1], left}), dist(j)); + curr[j] = left; + if (left < max_squared_dist) { + next_first = std::min(next_first, j); + next_last = j + 1; + } + } + + // Past the span only the cell on the left continues a path, plus the diagonal at j == last + for (std::size_t j = last; j < m; ++j) { + const double predecessor = j == last ? std::min(prev[last - 1], left) : left; + if (predecessor >= max_squared_dist) + break; + left = std::max(predecessor, dist(j)); + if (left >= max_squared_dist) + break; + curr[j] = left; + next_first = std::min(next_first, j); + next_last = j + 1; + } + + if (next_last == 0) + return inf; + first = next_first; + last = next_last; + std::swap(prev, curr); + } + return last == m ? prev[m - 1] : inf; +} + +double squared_endpoint_lower_bound(const core::TrajectoryView &query, + const core::TrajectoryView &reference) { + if (query.size() == 0 || reference.size() == 0) { + return std::numeric_limits::infinity(); + } + const std::size_t q_last = query.size() - 1; + const std::size_t r_last = reference.size() - 1; + return std::max(squared_point_dist(query.x[0], query.y[0], reference.x[0], reference.y[0]), + squared_point_dist(query.x[q_last], query.y[q_last], reference.x[r_last], + reference.y[r_last])); +} + +double frechet_distance(const core::TrajectoryView &a, const core::TrajectoryView &b) { + std::vector prev; + std::vector curr; + return std::sqrt(get_squared_frechet_distance(a, b, std::numeric_limits::infinity(), + prev, curr)); +} + +bool frechet_within(const core::TrajectoryView &a, const core::TrajectoryView &b, + double max_dist) { + // Values below the cutoff are exact, so anything at most max_dist^2 is found. The relative + // slack absorbs rounding when max_dist is itself a distance, as sqrt(d)^2 can fall below d. + const double cutoff = std::nextafter(max_dist * max_dist * (1.0 + 1e-12), + std::numeric_limits::infinity()); + std::vector prev; + std::vector curr; + return get_squared_frechet_distance(a, b, cutoff, prev, curr) < cutoff; +} + +double endpoint_lower_bound(const core::TrajectoryView &a, const core::TrajectoryView &b) { + return std::sqrt(squared_endpoint_lower_bound(a, b)); +} + +} // namespace query diff --git a/engine/src/query/join.cpp b/engine/src/query/join.cpp index 06bfb70..d9b23a0 100644 --- a/engine/src/query/join.cpp +++ b/engine/src/query/join.cpp @@ -1,5 +1,6 @@ #include "backend/engine/query/join.hpp" #include "backend/engine/core/parallel.hpp" +#include "backend/engine/query/frechet_helper.hpp" #include "backend/engine/query/knn.hpp" #include diff --git a/engine/src/query/knn.cpp b/engine/src/query/knn.cpp index c452443..8dd4a23 100644 --- a/engine/src/query/knn.cpp +++ b/engine/src/query/knn.cpp @@ -1,6 +1,7 @@ #include "backend/engine/query/knn.hpp" #include "backend/engine/core/parallel.hpp" #include "backend/engine/core/trajectory_db.hpp" +#include "backend/engine/query/frechet_helper.hpp" #include #include #include @@ -16,105 +17,6 @@ namespace query { namespace { -// Squared instead of std::hypot, which is slow. Fréchet only takes max and min of distances, -// and squaring keeps their order, so the whole computation can stay squared. -double squared_point_dist(double a_x, double a_y, double b_x, double b_y) { - const double dx = a_x - b_x; - const double dy = a_y - b_y; - return dx * dx + dy * dy; -} - -double get_squared_frechet_distance(const core::TrajectoryView &query, - const core::TrajectoryView &reference, double max_squared_dist, - std::vector &prev, std::vector &curr) { - const std::size_t n = query.size(); - const std::size_t m = reference.size(); - if (n == 0 || m == 0) { - return std::numeric_limits::infinity(); - } - - prev.resize(m); - curr.resize(m); - const double inf = std::numeric_limits::infinity(); - - // Only cells below the current k-th distance can improve the answer. Track the - // reachable span in each row instead of evaluating the entire n-by-m grid. - double value = 0.0; - std::size_t last = 0; - for (std::size_t j = 0; j < m; ++j) { - value = std::max( - value, squared_point_dist(query.x[0], query.y[0], reference.x[j], reference.y[j])); - if (value >= max_squared_dist) - break; - prev[j] = value; - last = j + 1; - } - if (last == 0) - return inf; - - // Cells at or above the cutoff keep their value instead of becoming infinity. They can only - // lead to values at or above the cutoff, so every value below it stays exact. - std::size_t first = 0; - for (std::size_t i = 1; i < n; ++i) { - const double qx = query.x[i]; - const double qy = query.y[i]; - const auto dist = [&](std::size_t j) { - return squared_point_dist(qx, qy, reference.x[j], reference.y[j]); - }; - std::size_t next_first = m; - std::size_t next_last = 0; - - // Within the previous row's reachable span [first, last), the cell above always exists, - // and the diagonal does for every cell but the first - double left = std::max(prev[first], dist(first)); - curr[first] = left; - if (left < max_squared_dist) { - next_first = first; - next_last = first + 1; - } - for (std::size_t j = first + 1; j < last; ++j) { - left = std::max(std::min({prev[j], prev[j - 1], left}), dist(j)); - curr[j] = left; - if (left < max_squared_dist) { - next_first = std::min(next_first, j); - next_last = j + 1; - } - } - - // Past the span only the cell on the left continues a path, plus the diagonal at j == last - for (std::size_t j = last; j < m; ++j) { - const double predecessor = j == last ? std::min(prev[last - 1], left) : left; - if (predecessor >= max_squared_dist) - break; - left = std::max(predecessor, dist(j)); - if (left >= max_squared_dist) - break; - curr[j] = left; - next_first = std::min(next_first, j); - next_last = j + 1; - } - - if (next_last == 0) - return inf; - first = next_first; - last = next_last; - std::swap(prev, curr); - } - return last == m ? prev[m - 1] : inf; -} - -double squared_endpoint_lower_bound(const core::TrajectoryView &query, - const core::TrajectoryView &reference) { - if (query.size() == 0 || reference.size() == 0) { - return std::numeric_limits::infinity(); - } - const std::size_t q_last = query.size() - 1; - const std::size_t r_last = reference.size() - 1; - return std::max(squared_point_dist(query.x[0], query.y[0], reference.x[0], reference.y[0]), - 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}); @@ -206,28 +108,6 @@ struct neighbor { } }; -double frechet_distance(const core::TrajectoryView &a, const core::TrajectoryView &b) { - std::vector prev; - std::vector curr; - return std::sqrt(get_squared_frechet_distance(a, b, std::numeric_limits::infinity(), - prev, curr)); -} - -bool frechet_within(const core::TrajectoryView &a, const core::TrajectoryView &b, - double max_dist) { - // Values below the cutoff are exact, so anything at most max_dist^2 is found. The relative - // slack absorbs rounding when max_dist is itself a distance, as sqrt(d)^2 can fall below d. - const double cutoff = std::nextafter(max_dist * max_dist * (1.0 + 1e-12), - std::numeric_limits::infinity()); - std::vector prev; - std::vector curr; - return get_squared_frechet_distance(a, b, cutoff, prev, curr) < cutoff; -} - -double endpoint_lower_bound(const core::TrajectoryView &a, const core::TrajectoryView &b) { - return std::sqrt(squared_endpoint_lower_bound(a, b)); -} - std::vector knn(const core::TrajectoryDb &trajectory_db, const core::TrajectoryView &query_trajectory, std::size_t k) { if (k == 0) { diff --git a/engine/tests/join_test.cpp b/engine/tests/join_test.cpp index 150e1ef..dbd34d4 100644 --- a/engine/tests/join_test.cpp +++ b/engine/tests/join_test.cpp @@ -14,7 +14,7 @@ #include "backend/engine/core/trajectory_db.hpp" #include "backend/engine/evaluator/join.hpp" #include "backend/engine/query/join.hpp" -#include "backend/engine/query/knn.hpp" +#include "backend/engine/query/frechet_helper.hpp" #include "backend/engine/selection/selector.hpp" namespace { From 996985ede31604af733d32b309533154e94e883b Mon Sep 17 00:00:00 2001 From: TB-PC Date: Wed, 4 Nov 2026 20:46:25 +0100 Subject: [PATCH 3/5] epsilon is always set through config, speedup 40s -> 10s --- engine/configs/geolife.json | 4 ++-- engine/include/backend/engine/query/join.hpp | 5 +--- engine/src/pipeline/config.cpp | 3 +-- engine/src/query/join.cpp | 24 ++------------------ engine/tests/config_test.cpp | 3 +-- engine/tests/join_test.cpp | 16 +++++-------- 6 files changed, 13 insertions(+), 42 deletions(-) diff --git a/engine/configs/geolife.json b/engine/configs/geolife.json index 7f5a176..df5a395 100644 --- a/engine/configs/geolife.json +++ b/engine/configs/geolife.json @@ -11,7 +11,7 @@ "algorithms": ["RDP", "SED"], "knn": { "k": 10, - "query_count": 0 + "query_count": 100 }, "range": { "query_count": 500, @@ -32,6 +32,6 @@ }, "join": { "query_count": 1000, - "eps_neighbor": 10 + "eps_m": 500.0 } } diff --git a/engine/include/backend/engine/query/join.hpp b/engine/include/backend/engine/query/join.hpp index 2a40c37..bf47f60 100644 --- a/engine/include/backend/engine/query/join.hpp +++ b/engine/include/backend/engine/query/join.hpp @@ -10,10 +10,7 @@ namespace query { struct JoinConfig { std::size_t query_count = 10; // 0 = use every trajectory in the query pool - double eps_m = 0.0; // join threshold in metres, 0 = derive it from the data - // With eps_m = 0, eps is the median over the queries of the Fréchet distance to their - // eps_neighbor-th nearest database trajectory, so a typical query has that many matches - std::size_t eps_neighbor = 10; + double eps_m = 500.0; // join threshold in metres, must be positive }; struct JoinQuery { diff --git a/engine/src/pipeline/config.cpp b/engine/src/pipeline/config.cpp index 0ac9abe..9a43674 100644 --- a/engine/src/pipeline/config.cpp +++ b/engine/src/pipeline/config.cpp @@ -85,10 +85,9 @@ void parse_where_at_scoring_config(Config &config, const json &scoring) { } void parse_join_config(Config &config, const json &join) { - validate_keys(join, {"query_count", "eps_m", "eps_neighbor"}, "join"); + validate_keys(join, {"query_count", "eps_m"}, "join"); config.join.query_count = join.value("query_count", config.join.query_count); config.join.eps_m = join.value("eps_m", config.join.eps_m); - config.join.eps_neighbor = join.value("eps_neighbor", config.join.eps_neighbor); } Config parse_config(const json &data) { diff --git a/engine/src/query/join.cpp b/engine/src/query/join.cpp index d9b23a0..1fb4813 100644 --- a/engine/src/query/join.cpp +++ b/engine/src/query/join.cpp @@ -1,7 +1,6 @@ #include "backend/engine/query/join.hpp" #include "backend/engine/core/parallel.hpp" #include "backend/engine/query/frechet_helper.hpp" -#include "backend/engine/query/knn.hpp" #include #include @@ -25,12 +24,6 @@ std::vector endpoint_candidates(const core::TrajectoryDb &trajector return candidates; } -double median(std::vector values) { - const auto middle = values.begin() + static_cast(values.size() / 2); - std::ranges::nth_element(values, middle); - return *middle; -} - } // namespace std::vector join(const core::TrajectoryDb &trajectories, @@ -46,10 +39,8 @@ std::vector join(const core::TrajectoryDb &trajectories, JoinWorkload generate_join_queries(const core::TrajectoryDb &original, const core::TrajectoryDb &query_pool, const JoinConfig &config, std::uint64_t seed) { - if (!std::isfinite(config.eps_m) || config.eps_m < 0.0) - throw std::invalid_argument("join: eps_m must be finite and non-negative"); - if (config.eps_m == 0.0 && config.eps_neighbor == 0) - throw std::invalid_argument("join: eps_neighbor must be positive when eps_m is 0"); + if (!std::isfinite(config.eps_m) || config.eps_m <= 0.0) + throw std::invalid_argument("join: eps_m must be finite and positive"); std::vector picked(query_pool.size()); std::iota(picked.begin(), picked.end(), 0); @@ -64,17 +55,6 @@ JoinWorkload generate_join_queries(const core::TrajectoryDb &original, if (n == 0 || original.size() == 0) return results; - if (results.eps_m == 0.0) { - const std::size_t rank = std::min(config.eps_neighbor, original.size()); - std::vector distances(n); - core::parallel_for(n, [&](std::size_t i) { - const auto query = results.queries.trajectory(i); - const auto neighbors = knn(original, query, rank); - distances[i] = frechet_distance(query, original.trajectory(neighbors.back())); - }); - results.eps_m = median(std::move(distances)); - } - results.entries.resize(n); core::parallel_for(n, [&](std::size_t i) { const auto query = results.queries.trajectory(i); diff --git a/engine/tests/config_test.cpp b/engine/tests/config_test.cpp index 9542a31..b9b12a5 100644 --- a/engine/tests/config_test.cpp +++ b/engine/tests/config_test.cpp @@ -39,11 +39,10 @@ TEST(Config, MissingKeysKeepDefaults) { TEST(Config, ReadsJoinKeys) { const auto config = pipeline::load_config(write_config("join.json", R"({ "data_path": "data.parquet", - "join": {"query_count": 0, "eps_m": 250, "eps_neighbor": 3} + "join": {"query_count": 0, "eps_m": 250} })")); EXPECT_EQ(config.join.query_count, 0); EXPECT_DOUBLE_EQ(config.join.eps_m, 250); - EXPECT_EQ(config.join.eps_neighbor, 3); } TEST(Config, ReadsTopLevelAndNestedKeys) { diff --git a/engine/tests/join_test.cpp b/engine/tests/join_test.cpp index dbd34d4..80aab84 100644 --- a/engine/tests/join_test.cpp +++ b/engine/tests/join_test.cpp @@ -115,24 +115,21 @@ TEST(Join, MatchesBruteForce) { } } -TEST(Join, WorkloadDerivesEpsFromNeighbourDistance) { +TEST(Join, WorkloadStoresTheJoinOnTheOriginalData) { std::mt19937_64 rng(13); const auto db = make_db(random_tracks(rng, 150)); const auto pool = make_db(random_tracks(rng, 30)); const auto workload = - query::generate_join_queries(db, pool, {.query_count = 0, .eps_neighbor = 5}); + query::generate_join_queries(db, pool, {.query_count = 0, .eps_m = 300.0}); ASSERT_EQ(workload.entries.size(), pool.size()); - EXPECT_GT(workload.eps_m, 0.0); - - // eps is the median distance to the 5th neighbour, so half the queries have at least 5 matches - std::size_t with_five = 0; + std::size_t pairs = 0; for (const auto &entry : workload.entries) { const auto query = workload.queries.trajectory(entry.query_index); EXPECT_EQ(entry.reference, query::join(db, query, workload.eps_m)); EXPECT_TRUE(std::ranges::includes(entry.candidates, entry.reference)); - with_five += entry.reference.size() >= 5; + pairs += entry.reference.size(); } - EXPECT_GE(with_five, pool.size() / 2); + EXPECT_GT(pairs, 0u); } TEST(Join, FixedEpsIsUsedAsGiven) { @@ -148,8 +145,7 @@ TEST(Join, FixedEpsIsUsedAsGiven) { TEST(Join, RejectsInvalidConfig) { const auto db = make_db({{{39.9, 116.3}, {39.91, 116.3}}}); EXPECT_THROW(query::generate_join_queries(db, db, {.eps_m = -1.0}), std::invalid_argument); - EXPECT_THROW(query::generate_join_queries(db, db, {.eps_m = 0.0, .eps_neighbor = 0}), - std::invalid_argument); + EXPECT_THROW(query::generate_join_queries(db, db, {.eps_m = 0.0}), std::invalid_argument); } TEST(JoinEvaluator, UnsimplifiedDatabaseScoresPerfectly) { From 705c14695ece0d4391d0ed6eddc9306eabbdec1a Mon Sep 17 00:00:00 2001 From: TB-PC Date: Wed, 4 Nov 2026 20:50:11 +0100 Subject: [PATCH 4/5] format --- engine/configs/geolife.json | 2 +- engine/include/backend/engine/evaluator/join.hpp | 1 - .../include/backend/engine/query/frechet_helper.hpp | 6 +----- engine/include/backend/engine/query/join.hpp | 8 +++----- engine/src/pipeline/pipeline.cpp | 13 ++++++------- engine/src/query/frechet_helper.cpp | 7 +++---- engine/tests/join_test.cpp | 2 +- 7 files changed, 15 insertions(+), 24 deletions(-) diff --git a/engine/configs/geolife.json b/engine/configs/geolife.json index df5a395..c8fd861 100644 --- a/engine/configs/geolife.json +++ b/engine/configs/geolife.json @@ -11,7 +11,7 @@ "algorithms": ["RDP", "SED"], "knn": { "k": 10, - "query_count": 100 + "query_count": 0 }, "range": { "query_count": 500, diff --git a/engine/include/backend/engine/evaluator/join.hpp b/engine/include/backend/engine/evaluator/join.hpp index 82f3fbc..99a3548 100644 --- a/engine/include/backend/engine/evaluator/join.hpp +++ b/engine/include/backend/engine/evaluator/join.hpp @@ -18,7 +18,6 @@ struct JoinScores { std::size_t query_count = 0; }; - JoinScores evaluate_join(const core::TrajectoryDb &compressed, const query::JoinWorkload &workload); } // namespace evaluator diff --git a/engine/include/backend/engine/query/frechet_helper.hpp b/engine/include/backend/engine/query/frechet_helper.hpp index 2657e45..53e6c03 100644 --- a/engine/include/backend/engine/query/frechet_helper.hpp +++ b/engine/include/backend/engine/query/frechet_helper.hpp @@ -20,18 +20,14 @@ double get_squared_frechet_distance(const core::TrajectoryView &query, const core::TrajectoryView &reference, double max_squared_dist, std::vector &prev, std::vector &curr); - double squared_endpoint_lower_bound(const core::TrajectoryView &query, const core::TrajectoryView &reference); - double frechet_distance(const core::TrajectoryView &a, const core::TrajectoryView &b); // Whether the discrete Fréchet distance is at most max_dist, up to a relative 1e-12 for // rounding. Stops as soon as it is not. -bool frechet_within(const core::TrajectoryView &a, const core::TrajectoryView &b, - double max_dist); - +bool frechet_within(const core::TrajectoryView &a, const core::TrajectoryView &b, double max_dist); double endpoint_lower_bound(const core::TrajectoryView &a, const core::TrajectoryView &b); diff --git a/engine/include/backend/engine/query/join.hpp b/engine/include/backend/engine/query/join.hpp index bf47f60..7e25e45 100644 --- a/engine/include/backend/engine/query/join.hpp +++ b/engine/include/backend/engine/query/join.hpp @@ -14,12 +14,11 @@ struct JoinConfig { }; struct JoinQuery { - std::size_t query_index; - std::vector reference; - std::vector candidates; + std::size_t query_index; + std::vector reference; + std::vector candidates; }; - struct JoinWorkload { core::TrajectoryDb queries; std::vector entries; @@ -32,7 +31,6 @@ JoinWorkload generate_join_queries(const core::TrajectoryDb &original, const core::TrajectoryDb &query_pool, const JoinConfig &config = {}, std::uint64_t seed = 42); - std::vector join(const core::TrajectoryDb &trajectories, const core::TrajectoryView &query, double eps); diff --git a/engine/src/pipeline/pipeline.cpp b/engine/src/pipeline/pipeline.cpp index 6395f4d..d45222d 100644 --- a/engine/src/pipeline/pipeline.cpp +++ b/engine/src/pipeline/pipeline.cpp @@ -96,13 +96,12 @@ void run(const Config &config) { std::cout << share * 100 << "% " << magic_enum::enum_name(algorithm) << ": "; print_size("compressed", compressed); - const evaluator::Evaluator evaluator( - database, compressed, - {.knn = &knn_workload, - .where_at = where_at.queries, - .range = range.queries, - .join = &join}, - config.where_at_scoring); + const evaluator::Evaluator evaluator(database, compressed, + {.knn = &knn_workload, + .where_at = where_at.queries, + .range = range.queries, + .join = &join}, + config.where_at_scoring); const auto evaluation = evaluator.evaluate(); print_scores(evaluation.scores); diff --git a/engine/src/query/frechet_helper.cpp b/engine/src/query/frechet_helper.cpp index df5eff7..ea76914 100644 --- a/engine/src/query/frechet_helper.cpp +++ b/engine/src/query/frechet_helper.cpp @@ -103,12 +103,11 @@ double squared_endpoint_lower_bound(const core::TrajectoryView &query, double frechet_distance(const core::TrajectoryView &a, const core::TrajectoryView &b) { std::vector prev; std::vector curr; - return std::sqrt(get_squared_frechet_distance(a, b, std::numeric_limits::infinity(), - prev, curr)); + return std::sqrt( + get_squared_frechet_distance(a, b, std::numeric_limits::infinity(), prev, curr)); } -bool frechet_within(const core::TrajectoryView &a, const core::TrajectoryView &b, - double max_dist) { +bool frechet_within(const core::TrajectoryView &a, const core::TrajectoryView &b, double max_dist) { // Values below the cutoff are exact, so anything at most max_dist^2 is found. The relative // slack absorbs rounding when max_dist is itself a distance, as sqrt(d)^2 can fall below d. const double cutoff = std::nextafter(max_dist * max_dist * (1.0 + 1e-12), diff --git a/engine/tests/join_test.cpp b/engine/tests/join_test.cpp index 80aab84..4404411 100644 --- a/engine/tests/join_test.cpp +++ b/engine/tests/join_test.cpp @@ -13,8 +13,8 @@ #include "backend/engine/core/simplification.hpp" #include "backend/engine/core/trajectory_db.hpp" #include "backend/engine/evaluator/join.hpp" -#include "backend/engine/query/join.hpp" #include "backend/engine/query/frechet_helper.hpp" +#include "backend/engine/query/join.hpp" #include "backend/engine/selection/selector.hpp" namespace { From 64a7e377951a04690480acc7374da84e2dc7bd89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristoffer=20Vesterg=C3=A5rd=20Johansson?= <108988684+Kristofferjoha@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:08:44 +0100 Subject: [PATCH 5/5] Set query_count to 0 in geolife.json --- engine/configs/geolife.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/configs/geolife.json b/engine/configs/geolife.json index c8fd861..29e6d8a 100644 --- a/engine/configs/geolife.json +++ b/engine/configs/geolife.json @@ -31,7 +31,7 @@ "max_tolerance_m": 500.0 }, "join": { - "query_count": 1000, + "query_count": 0, "eps_m": 500.0 } }