From 67d5170bb92d5843f674aa47a2dc1e98fd2e1bbb Mon Sep 17 00:00:00 2001 From: Kristofferjoha Date: Thu, 5 Nov 2026 14:39:37 +0100 Subject: [PATCH 1/2] added join, fixed metrics --- .gitignore | 1 + .../include/backend/data/filters/timegap.hpp | 2 +- engine/configs/geolife.json | 6 +- .../backend/engine/evaluator/combined.hpp | 8 +- .../backend/engine/evaluator/evaluator.hpp | 3 + .../include/backend/engine/evaluator/join.hpp | 14 +- .../backend/engine/evaluator/range.hpp | 4 + engine/include/backend/engine/query/join.hpp | 16 +- engine/src/evaluator/combined.cpp | 8 +- engine/src/evaluator/evaluator.cpp | 7 +- engine/src/evaluator/join.cpp | 47 +++++- engine/src/evaluator/range.cpp | 23 ++- engine/src/pipeline/config.cpp | 4 +- engine/src/pipeline/pipeline.cpp | 8 +- engine/src/query/join.cpp | 34 ++++ engine/tests/combined_test.cpp | 44 +++++- engine/tests/config_test.cpp | 12 ++ engine/tests/evaluator_test.cpp | 14 ++ engine/tests/join_test.cpp | 146 ++++++++++++++++++ engine/tests/range_test.cpp | 12 ++ 20 files changed, 379 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index b5bad30..c8abe9a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Build output build/ +build*/ # Editor and tooling .cache/ diff --git a/data_processing/include/backend/data/filters/timegap.hpp b/data_processing/include/backend/data/filters/timegap.hpp index 8a833ba..17da009 100644 --- a/data_processing/include/backend/data/filters/timegap.hpp +++ b/data_processing/include/backend/data/filters/timegap.hpp @@ -7,7 +7,7 @@ namespace filters { -inline constexpr std::int64_t kMaxGapSeconds = 5 * 60; +inline constexpr std::int64_t kMaxGapSeconds = 60 * 60; std::vector split_by_time_gap(const geolife::Segment &segment, std::int64_t max_gap_s); diff --git a/engine/configs/geolife.json b/engine/configs/geolife.json index 29e6d8a..6cfbf36 100644 --- a/engine/configs/geolife.json +++ b/engine/configs/geolife.json @@ -14,7 +14,7 @@ "query_count": 0 }, "range": { - "query_count": 500, + "query_count": 20000, "box_size_m": 2000.0, "window_seconds": 604800, "min_results": 5, @@ -26,12 +26,12 @@ "max_gap_seconds": 60.0 }, "where_at_scoring": { - "hit_radius_m": 100.0, + "hit_radius_m": 150.0, "min_tolerance_m": 25.0, "max_tolerance_m": 500.0 }, "join": { - "query_count": 0, + "mode": "Self", "eps_m": 500.0 } } diff --git a/engine/include/backend/engine/evaluator/combined.hpp b/engine/include/backend/engine/evaluator/combined.hpp index 399b60a..3d4256d 100644 --- a/engine/include/backend/engine/evaluator/combined.hpp +++ b/engine/include/backend/engine/evaluator/combined.hpp @@ -6,14 +6,18 @@ namespace evaluator { -// 0 = the floor (endpoints only), 1 = perfect. Query types average their own scores and combined -// averages the types, skipping NaN. +// 0 = the floor (endpoints only), 1 = perfect, without clipping. Combined averages +// one primary score per type: recall@k, range F1, where-at within-radius hit rate, join F1. +// Missing primaries are skipped, never replaced with a diagnostic score. +// The radius is configured by hit_radius_m (100 m for the research protocol). +// Average-hit remains a diagnostic and does not contribute to the combined score. struct NormalizedScores { static constexpr double missing = std::numeric_limits::quiet_NaN(); double knn_recall_at_k = missing; double knn_kendalls_tau_topk = missing; double range_recall = missing; + double range_f1 = missing; double where_at_within_radius = missing; double where_at_average_hit = missing; double join_f1 = missing; diff --git a/engine/include/backend/engine/evaluator/evaluator.hpp b/engine/include/backend/engine/evaluator/evaluator.hpp index 9d1c15b..dbb2d70 100644 --- a/engine/include/backend/engine/evaluator/evaluator.hpp +++ b/engine/include/backend/engine/evaluator/evaluator.hpp @@ -8,6 +8,7 @@ #include "backend/engine/query/knn.hpp" #include "backend/engine/query/range_query.hpp" +#include #include namespace evaluator { @@ -26,6 +27,7 @@ struct Scoretable { double range_recall; WhereAtScores where_at; JoinScores join; + double range_f1 = std::numeric_limits::quiet_NaN(); }; struct EvaluationResult { @@ -44,6 +46,7 @@ class Evaluator { // Each runs only its own evaluation KnnScores get_knn_scores() const; double get_range_recall() const; + double get_range_f1() const; WhereAtScores get_where_at_scores() const; JoinScores get_join_scores() const; diff --git a/engine/include/backend/engine/evaluator/join.hpp b/engine/include/backend/engine/evaluator/join.hpp index 99a3548..1561da4 100644 --- a/engine/include/backend/engine/evaluator/join.hpp +++ b/engine/include/backend/engine/evaluator/join.hpp @@ -8,14 +8,22 @@ 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. +// HeldOut mode averages over queries. Self mode uses global unordered-pair micro scores. +// Self precision/recall are NaN for empty prediction/reference respectively; F1 is NaN +// only when both pair sets are empty, otherwise 2*TP/(predicted+reference). +// HeldOut: 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; + std::size_t query_count = 0; // held-out queries only; zero for Self + query::JoinMode mode = query::JoinMode::HeldOut; + std::size_t candidate_pairs = 0; + std::size_t reference_pairs = 0; + std::size_t predicted_pairs = 0; + std::size_t true_positive_pairs = 0; }; JoinScores evaluate_join(const core::TrajectoryDb &compressed, const query::JoinWorkload &workload); diff --git a/engine/include/backend/engine/evaluator/range.hpp b/engine/include/backend/engine/evaluator/range.hpp index d06b34f..9065df6 100644 --- a/engine/include/backend/engine/evaluator/range.hpp +++ b/engine/include/backend/engine/evaluator/range.hpp @@ -13,6 +13,7 @@ namespace evaluator { struct RangeEvaluation { double recall = std::numeric_limits::quiet_NaN(); + double f1 = std::numeric_limits::quiet_NaN(); std::vector> answers; }; @@ -26,4 +27,7 @@ namespace evaluator::range { // Share of original that also appears in simplified. NaN if original is empty. double recall(const std::vector &original, const std::vector &simplified); +// F1 over trajectory membership; NaN when both result sets are empty. +double f1(const std::vector &original, const std::vector &simplified); + } // namespace evaluator::range diff --git a/engine/include/backend/engine/query/join.hpp b/engine/include/backend/engine/query/join.hpp index 7e25e45..d5c0921 100644 --- a/engine/include/backend/engine/query/join.hpp +++ b/engine/include/backend/engine/query/join.hpp @@ -8,9 +8,12 @@ namespace query { +enum class JoinMode { HeldOut, Self }; + struct JoinConfig { - std::size_t query_count = 10; // 0 = use every trajectory in the query pool - double eps_m = 500.0; // join threshold in metres, must be positive + std::size_t query_count = 10; // 0 = use every trajectory in the query pool + double eps_m = 500.0; // join threshold in metres, must be positive + JoinMode mode = JoinMode::HeldOut; // Self ignores query_count and the query pool }; struct JoinQuery { @@ -20,17 +23,24 @@ struct JoinQuery { }; struct JoinWorkload { + // In Self mode this stores original endpoints/IDs for validation, not query inputs. core::TrajectoryDb queries; std::vector entries; double eps_m = 0.0; + JoinMode mode = JoinMode::HeldOut; + // Self entries contain only indices j > i; both operands are evaluated compressed. }; // Samples query_count trajectories from query_pool (the held-out split) and stores their join -// result in original as the reference answer +// result in original as the reference answer. Self mode delegates to generate_self_join. JoinWorkload generate_join_queries(const core::TrajectoryDb &original, const core::TrajectoryDb &query_pool, const JoinConfig &config = {}, std::uint64_t seed = 42); +// All unordered database pairs, endpoint-pruned, with reference truth on originals. +// No held-out queries or sampling. Nonempty trajectories must retain their endpoints/IDs/order. +JoinWorkload generate_self_join(const core::TrajectoryDb &original, double eps_m = 500.0); + std::vector join(const core::TrajectoryDb &trajectories, const core::TrajectoryView &query, double eps); diff --git a/engine/src/evaluator/combined.cpp b/engine/src/evaluator/combined.cpp index fa60bc4..2be9b7b 100644 --- a/engine/src/evaluator/combined.cpp +++ b/engine/src/evaluator/combined.cpp @@ -35,16 +35,16 @@ NormalizedScores normalize_scores(const Scoretable &scores, const Scoretable &fl result.knn_kendalls_tau_topk = normalize(scores.knn.kendalls_tau_topk, floor.knn.kendalls_tau_topk); result.range_recall = normalize(scores.range_recall, floor.range_recall); + result.range_f1 = normalize(scores.range_f1, floor.range_f1); result.where_at_within_radius = normalize(scores.where_at.within_radius, floor.where_at.within_radius); result.where_at_average_hit = normalize(scores.where_at.average_hit, floor.where_at.average_hit); result.join_f1 = normalize(scores.join.f1, floor.join.f1); - result.knn = mean_ignoring_nan({result.knn_recall_at_k, result.knn_kendalls_tau_topk}); - result.range = result.range_recall; - result.where_at = - mean_ignoring_nan({result.where_at_within_radius, result.where_at_average_hit}); + result.knn = result.knn_recall_at_k; + result.range = result.range_f1; + result.where_at = result.where_at_within_radius; result.join = result.join_f1; result.combined = mean_ignoring_nan({result.knn, result.range, result.where_at, result.join}); diff --git a/engine/src/evaluator/evaluator.cpp b/engine/src/evaluator/evaluator.cpp index e34f47d..9faf8a7 100644 --- a/engine/src/evaluator/evaluator.cpp +++ b/engine/src/evaluator/evaluator.cpp @@ -26,7 +26,8 @@ EvaluationResult Evaluator::evaluate() const { return {.scores = {.knn = knn.scores, .range_recall = range.recall, .where_at = get_where_at_scores(), - .join = get_join_scores()}, + .join = get_join_scores(), + .range_f1 = range.f1}, .knn = std::move(knn.answers), .range = std::move(range.answers)}; } @@ -39,6 +40,10 @@ double Evaluator::get_range_recall() const { return evaluate_range(m_simplified, m_workloads.range).recall; } +double Evaluator::get_range_f1() const { + return evaluate_range(m_simplified, m_workloads.range).f1; +} + WhereAtScores Evaluator::get_where_at_scores() const { return evaluate_where_at(m_simplified, m_workloads.where_at, m_where_at_config); } diff --git a/engine/src/evaluator/join.cpp b/engine/src/evaluator/join.cpp index 13648b9..29fdd7c 100644 --- a/engine/src/evaluator/join.cpp +++ b/engine/src/evaluator/join.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include namespace evaluator { @@ -34,13 +35,30 @@ double average(const std::vector &values) { JoinScores evaluate_join(const core::TrajectoryDb &compressed, const query::JoinWorkload &workload) { + const bool self = workload.mode == query::JoinMode::Self; + if (self) { + if (compressed.size() != workload.queries.size() || + workload.entries.size() != compressed.size()) + throw std::invalid_argument("self join: database size does not match reference"); + for (std::size_t i = 0; i < compressed.size(); ++i) { + const auto a = compressed.trajectory(i); + const auto b = workload.queries.trajectory(i); + if (a.size() == 0 || b.size() == 0 || a.trajectory_id != b.trajectory_id || + a.x.front() != b.x.front() || a.y.front() != b.y.front() || + a.x.back() != b.x.back() || a.y.back() != b.y.back()) + throw std::invalid_argument( + "self join: IDs/order or endpoints do not match reference"); + } + } const std::size_t n = workload.entries.size(); + std::vector totals(n); 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); + const auto query = self ? compressed.trajectory(entry.query_index) + : workload.queries.trajectory(entry.query_index); // Simplification keeps endpoints, so the original candidates cover every possible match Counts counts; @@ -52,6 +70,7 @@ JoinScores evaluate_join(const core::TrajectoryDb &compressed, ++counts.hits; } + totals[i] = counts; const auto hits = static_cast(counts.hits); const auto found = static_cast(counts.found); const auto truth = static_cast(entry.reference.size()); @@ -63,11 +82,27 @@ JoinScores evaluate_join(const core::TrajectoryDb &compressed, 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}; + JoinScores result{.f1 = average(f1), + .precision = average(precision), + .recall = average(recall), + .eps_m = workload.eps_m, + .query_count = self ? 0 : n, + .mode = workload.mode}; + for (std::size_t i = 0; i < n; ++i) { + result.candidate_pairs += workload.entries[i].candidates.size(); + result.reference_pairs += workload.entries[i].reference.size(); + result.predicted_pairs += totals[i].found; + result.true_positive_pairs += totals[i].hits; + } + if (self) { + const double hits = static_cast(result.true_positive_pairs); + const double found = static_cast(result.predicted_pairs); + const double truth = static_cast(result.reference_pairs); + result.precision = found == 0 ? nan : hits / found; + result.recall = truth == 0 ? nan : hits / truth; + result.f1 = found + truth == 0 ? nan : 2.0 * hits / (found + truth); + } + return result; } } // namespace evaluator diff --git a/engine/src/evaluator/range.cpp b/engine/src/evaluator/range.cpp index e1e10de..0e3aec6 100644 --- a/engine/src/evaluator/range.cpp +++ b/engine/src/evaluator/range.cpp @@ -15,7 +15,7 @@ RangeEvaluation evaluate_range(const core::TrajectoryDb &compressed, result.answers.resize(queries.size()); double sum = 0.0; std::size_t scored = 0; - std::vector recalls(queries.size()); + std::vector recalls(queries.size()), f1s(queries.size()); core::parallel_for(queries.size(), [&](std::size_t i) { const auto &range_query = queries[i]; const auto simplified = query::range(compressed, range_query.box); @@ -24,6 +24,7 @@ RangeEvaluation evaluate_range(const core::TrajectoryDb &compressed, for (const auto index : simplified) answer.push_back(compressed.trajectory(index).trajectory_id); recalls[i] = range::recall(range_query.reference, simplified); + f1s[i] = range::f1(range_query.reference, simplified); }); for (const double recall : recalls) { if (!std::isnan(recall)) { @@ -33,6 +34,16 @@ RangeEvaluation evaluate_range(const core::TrajectoryDb &compressed, } if (scored != 0) result.recall = sum / static_cast(scored); + sum = 0.0; + scored = 0; + for (const double f1 : f1s) { + if (!std::isnan(f1)) { + sum += f1; + ++scored; + } + } + if (scored != 0) + result.f1 = sum / static_cast(scored); return result; } @@ -52,4 +63,14 @@ double recall(const std::vector &original, return static_cast(hits) / static_cast(original.size()); } +double f1(const std::vector &original, const std::vector &simplified) { + if (original.empty() && simplified.empty()) + return std::numeric_limits::quiet_NaN(); + std::size_t hits = 0; + for (const auto i : simplified) + hits += std::ranges::binary_search(original, i); + return 2.0 * static_cast(hits) / + (static_cast(original.size()) + static_cast(simplified.size())); +} + } // namespace evaluator::range diff --git a/engine/src/pipeline/config.cpp b/engine/src/pipeline/config.cpp index 9a43674..3b66c0a 100644 --- a/engine/src/pipeline/config.cpp +++ b/engine/src/pipeline/config.cpp @@ -85,7 +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"}, "join"); + validate_keys(join, {"query_count", "eps_m", "mode"}, "join"); + if (join.contains("mode")) + config.join.mode = parse_enum(join["mode"]); config.join.query_count = join.value("query_count", config.join.query_count); config.join.eps_m = join.value("eps_m", config.join.eps_m); } diff --git a/engine/src/pipeline/pipeline.cpp b/engine/src/pipeline/pipeline.cpp index 98d05e5..be81f03 100644 --- a/engine/src/pipeline/pipeline.cpp +++ b/engine/src/pipeline/pipeline.cpp @@ -56,7 +56,7 @@ void print_scores(const evaluator::Scoretable &scores) { << " queries)\n"; 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 << " range F1: " << scores.range_f1 << ", recall: " << scores.range_recall << '\n'; std::cout << " join F1: " << scores.join.f1 << ", precision: " << scores.join.precision << ", recall: " << scores.join.recall << '\n'; } @@ -100,8 +100,10 @@ void run(const Config &config) { 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"; + std::cout << "join " << magic_enum::enum_name(join.mode) << ": " << join.entries.size() + << (join.mode == query::JoinMode::Self ? " database trajectories, eps=" + : " 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); diff --git a/engine/src/query/join.cpp b/engine/src/query/join.cpp index 1fb4813..2e378a5 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/core/simplification.hpp" #include "backend/engine/query/frechet_helper.hpp" #include @@ -42,6 +43,9 @@ JoinWorkload generate_join_queries(const core::TrajectoryDb &original, if (!std::isfinite(config.eps_m) || config.eps_m <= 0.0) throw std::invalid_argument("join: eps_m must be finite and positive"); + if (config.mode == JoinMode::Self) + return generate_self_join(original, config.eps_m); + std::vector picked(query_pool.size()); std::iota(picked.begin(), picked.end(), 0); if (config.query_count != 0 && config.query_count < picked.size()) { @@ -69,4 +73,34 @@ JoinWorkload generate_join_queries(const core::TrajectoryDb &original, return results; } +JoinWorkload generate_self_join(const core::TrajectoryDb &original, double eps_m) { + if (!std::isfinite(eps_m) || eps_m <= 0.0) + throw std::invalid_argument("self join: eps_m must be finite and positive"); + core::Simplification endpoints; + for (std::size_t i = 0; i < original.size(); ++i) { + const auto n = original.trajectory(i).size(); + if (n == 0) + throw std::invalid_argument("self join: empty trajectory"); + const std::vector kept = + n == 1 ? std::vector{0} : std::vector{0, n - 1}; + endpoints.add_trajectory(kept); + } + JoinWorkload result{core::materialize(original, endpoints), {}, eps_m, JoinMode::Self}; + result.entries.resize(original.size()); + core::parallel_for(original.size(), [&](std::size_t i) { + auto &entry = result.entries[i]; + entry.query_index = i; + const auto a = original.trajectory(i); + for (std::size_t j = i + 1; j < original.size(); ++j) { + const auto b = original.trajectory(j); + if (endpoint_lower_bound(a, b) > eps_m) + continue; + entry.candidates.push_back(j); + if (frechet_within(a, b, eps_m)) + entry.reference.push_back(j); + } + }); + return result; +} + } // namespace query diff --git a/engine/tests/combined_test.cpp b/engine/tests/combined_test.cpp index b91c515..6c2a8dd 100644 --- a/engine/tests/combined_test.cpp +++ b/engine/tests/combined_test.cpp @@ -14,6 +14,7 @@ evaluator::Scoretable all_scores(double value) { scores.knn.recall_at_k = value; scores.knn.kendalls_tau_topk = value; scores.range_recall = value; + scores.range_f1 = value; scores.where_at.within_radius = value; scores.where_at.average_hit = value; scores.join.f1 = value; @@ -51,15 +52,15 @@ TEST(NormalizeScores, WeighsQueryTypesEquallyNotScores) { const auto floor = all_scores(0.0); auto scores = all_scores(0.0); scores.knn.recall_at_k = 1.0; - scores.range_recall = 1.0; + scores.range_f1 = 1.0; const auto normalized = evaluator::normalize_scores(scores, floor); EXPECT_DOUBLE_EQ(normalized.knn_recall_at_k, 1.0); - EXPECT_DOUBLE_EQ(normalized.knn, 0.5); + EXPECT_DOUBLE_EQ(normalized.knn, 1.0); EXPECT_DOUBLE_EQ(normalized.range, 1.0); EXPECT_DOUBLE_EQ(normalized.where_at, 0.0); EXPECT_DOUBLE_EQ(normalized.join, 0.0); - // (0.5 + 1 + 0 + 0) / 4, not a flat 2 / 6 over the scores - EXPECT_DOUBLE_EQ(normalized.combined, 0.375); + // (1 + 1 + 0 + 0) / 4; diagnostics do not contribute. + EXPECT_DOUBLE_EQ(normalized.combined, 0.5); } TEST(NormalizeScores, SkipsMissingQueryTypes) { @@ -67,10 +68,10 @@ TEST(NormalizeScores, SkipsMissingQueryTypes) { auto scores = all_scores(0.75); floor.join.f1 = missing; scores.join.f1 = missing; - scores.where_at.average_hit = missing; + scores.where_at.within_radius = missing; const auto normalized = evaluator::normalize_scores(scores, floor); EXPECT_TRUE(std::isnan(normalized.join)); - EXPECT_DOUBLE_EQ(normalized.where_at, 0.5); + EXPECT_TRUE(std::isnan(normalized.where_at)); EXPECT_DOUBLE_EQ(normalized.combined, 0.5); } @@ -79,3 +80,34 @@ TEST(NormalizeScores, AllMissingIsNan) { EXPECT_TRUE(std::isnan(normalized.knn)); EXPECT_TRUE(std::isnan(normalized.combined)); } + +TEST(NormalizeScores, DiagnosticsNeverChangePrimaryScores) { + const auto floor = all_scores(0.0); + auto scores = all_scores(0.5); + scores.knn.kendalls_tau_topk = -1.0; + scores.range_recall = 0.0; + scores.where_at.average_hit = 1.0; + const auto normalized = evaluator::normalize_scores(scores, floor); + EXPECT_DOUBLE_EQ(normalized.combined, 0.5); + EXPECT_DOUBLE_EQ(normalized.knn_kendalls_tau_topk, -1.0); + EXPECT_DOUBLE_EQ(normalized.range_recall, 0.0); + EXPECT_DOUBLE_EQ(normalized.where_at_average_hit, 1.0); + scores.knn.recall_at_k = missing; + scores.range_f1 = missing; + scores.where_at.within_radius = missing; + scores.join.f1 = missing; + EXPECT_TRUE(std::isnan(evaluator::normalize_scores(scores, floor).combined)); +} + +TEST(NormalizeScores, WhereAtUsesHitRateAndItsOwnEndpointFloor) { + auto floor = all_scores(0.0); + auto scores = all_scores(0.0); + floor.where_at.within_radius = 0.2; + scores.where_at.within_radius = 0.6; + floor.where_at.average_hit = 0.4; + scores.where_at.average_hit = 1.0; + const auto normalized = evaluator::normalize_scores(scores, floor); + EXPECT_DOUBLE_EQ(normalized.where_at, 0.5); + EXPECT_DOUBLE_EQ(normalized.where_at_average_hit, 1.0); + EXPECT_DOUBLE_EQ(normalized.combined, 0.125); +} diff --git a/engine/tests/config_test.cpp b/engine/tests/config_test.cpp index b9b12a5..b5ad4d3 100644 --- a/engine/tests/config_test.cpp +++ b/engine/tests/config_test.cpp @@ -157,3 +157,15 @@ TEST(Config, ExportDirectoryDoesNotEnableExport) { R"({"data_path":"data.parquet","export_results":true,"export_dir":""})")), std::invalid_argument); } + +TEST(Config, SelfJoinModeIsExplicitAndDefaultsToHeldOut) { + EXPECT_EQ(pipeline::Config{}.join.mode, query::JoinMode::HeldOut); + const auto config = pipeline::load_config(write_config("self_join.json", R"({ + "data_path": "data.parquet", "join": {"mode": "Self", "eps_m": 500} + })")); + EXPECT_EQ(config.join.mode, query::JoinMode::Self); + EXPECT_THROW(pipeline::load_config(write_config("bad_join_mode.json", R"({ + "data_path": "data.parquet", "join": {"mode": "typo"} + })")), + std::invalid_argument); +} diff --git a/engine/tests/evaluator_test.cpp b/engine/tests/evaluator_test.cpp index 64ded79..c21105d 100644 --- a/engine/tests/evaluator_test.cpp +++ b/engine/tests/evaluator_test.cpp @@ -137,3 +137,17 @@ TEST(KnnScores, EmptyWorkloadHasZeroCoverageAndUndefinedScores) { EXPECT_TRUE(std::isnan(scores.kendalls_tau_topk)); EXPECT_TRUE(std::isnan(scores.recall_at_k)); } + +TEST(Evaluator, ExposesRangeF1AndSelfJoinTogether) { + const auto db = original(); + const auto compressed = endpoints(db); + const auto a = db.trajectory(0); + const std::vector queries{ + {{a.x[0] - 1, a.x[0] + 1, a.y[0] - 1, a.y[0] + 1, 0, 0}, {0}}}; + const auto join = query::generate_self_join(db); + const evaluator::Evaluator eval(db, compressed, {.range = queries, .join = &join}); + EXPECT_DOUBLE_EQ(eval.get_range_f1(), 1); + EXPECT_DOUBLE_EQ(eval.evaluate().scores.range_f1, 1); + EXPECT_EQ(eval.get_join_scores().mode, query::JoinMode::Self); + EXPECT_TRUE(std::isnan(eval.get_join_scores().f1)); // one trajectory: no unordered pairs +} diff --git a/engine/tests/join_test.cpp b/engine/tests/join_test.cpp index 4404411..c6e0bb2 100644 --- a/engine/tests/join_test.cpp +++ b/engine/tests/join_test.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -196,3 +197,148 @@ TEST(JoinEvaluator, EmptyWorkloadIsNaN) { EXPECT_TRUE(std::isnan(scores.f1)); EXPECT_EQ(scores.query_count, 0u); } + +TEST(SelfJoin, BothOperandsAreSimplifiedAndPairsAreUnordered) { + const Track bend{{39.9, 116.3}, {39.92, 116.3}, {39.9, 116.31}}; + const auto db = make_db({bend, bend}); + const auto workload = query::generate_self_join(db, 500); + ASSERT_EQ(workload.entries.size(), 2u); + EXPECT_EQ(workload.entries[0].candidates, (std::vector{1})); + EXPECT_EQ(workload.entries[0].reference, (std::vector{1})); + EXPECT_TRUE(workload.entries[1].candidates.empty()); + core::Simplification one, both; + const std::vector endpoints{0, 2}, full{0, 1, 2}; + one.add_trajectory(endpoints); + one.add_trajectory(full); + both.add_trajectory(endpoints); + both.add_trajectory(endpoints); + EXPECT_DOUBLE_EQ(evaluator::evaluate_join(core::materialize(db, one), workload).f1, 0); + const auto scores = evaluator::evaluate_join(core::materialize(db, both), workload); + EXPECT_DOUBLE_EQ(scores.f1, 1); + EXPECT_EQ(scores.reference_pairs, 1u); + EXPECT_EQ(scores.predicted_pairs, 1u); + EXPECT_EQ(scores.true_positive_pairs, 1u); + EXPECT_EQ(scores.query_count, 0u); + EXPECT_EQ(scores.mode, query::JoinMode::Self); +} + +TEST(SelfJoin, CountsFalsePositivesAndUsesMicroRatherThanRowMean) { + const Track up{{39.9, 116.3}, {39.92, 116.3}, {39.9, 116.31}}; + const Track down{{39.9, 116.3}, {39.88, 116.3}, {39.9, 116.31}}; + const auto db = make_db({up, up, down}); + const auto workload = query::generate_self_join(db); + core::Simplification s; + const std::vector endpoints{0, 2}; + for (int i = 0; i < 3; ++i) + s.add_trajectory(endpoints); + const auto scores = evaluator::evaluate_join(core::materialize(db, s), workload); + EXPECT_EQ(scores.candidate_pairs, 3u); + EXPECT_EQ(scores.reference_pairs, 1u); + EXPECT_EQ(scores.predicted_pairs, 3u); + EXPECT_EQ(scores.true_positive_pairs, 1u); + EXPECT_DOUBLE_EQ(scores.precision, 1.0 / 3); + EXPECT_DOUBLE_EQ(scores.recall, 1); + EXPECT_DOUBLE_EQ(scores.f1, 0.5); +} + +TEST(SelfJoin, MatchesIndependentFullGridOnAllOriginalAndCompressedPairs) { + std::mt19937_64 rng(478); + const auto db = make_db(random_tracks(rng, 30)); + core::Simplification s; + for (std::size_t i = 0; i < db.size(); ++i) { + const auto n = db.trajectory(i).size(); + std::vector kept{0}; + for (std::size_t k = 1; k + 1 < n; ++k) + if (rng() % 3 == 0) + kept.push_back(k); + kept.push_back(n - 1); + s.add_trajectory(kept); + } + const auto compressed = core::materialize(db, s); + for (double eps : {100., 500., 1500.}) { + const auto workload = query::generate_self_join(db, eps); + std::size_t truth = 0, predicted = 0, hits = 0; + for (std::size_t i = 0; i < db.size(); ++i) { + std::vector expected; + for (std::size_t j = i + 1; j < db.size(); ++j) { + const bool ref = frechet(db.trajectory(i), db.trajectory(j)) <= eps; + const bool pred = + frechet(compressed.trajectory(i), compressed.trajectory(j)) <= eps; + if (ref) + expected.push_back(j); + truth += ref; + predicted += pred; + hits += ref && pred; + } + EXPECT_EQ(workload.entries[i].reference, expected); + } + const auto scores = evaluator::evaluate_join(compressed, workload); + EXPECT_EQ(scores.reference_pairs, truth); + EXPECT_EQ(scores.predicted_pairs, predicted); + EXPECT_EQ(scores.true_positive_pairs, hits); + if (truth + predicted) + EXPECT_DOUBLE_EQ(scores.f1, 2.0 * hits / (truth + predicted)); + else + EXPECT_TRUE(std::isnan(scores.f1)); + if (truth) + EXPECT_DOUBLE_EQ(evaluator::evaluate_join(db, workload).f1, 1); + } +} + +TEST(SelfJoin, ConfigModeIgnoresHeldOutPoolSamplingAndSeed) { + const auto db = make_db({{{39.9, 116.3}}, {{39.9, 116.3}}, {{39.9, 116.3}}}); + const query::JoinConfig config{.query_count = 1, .mode = query::JoinMode::Self}; + const auto workload = query::generate_join_queries(db, make_db({}), config, 912); + const auto scores = evaluator::evaluate_join(db, workload); + EXPECT_EQ(scores.reference_pairs, 3u); + EXPECT_DOUBLE_EQ(scores.f1, 1); +} + +TEST(SelfJoin, IncludesThresholdBoundaryAndPrunesEitherEndpoint) { + const auto db = make_db({{{39.9, 116.3}, {39.9, 116.31}}, + {{39.901, 116.3}, {39.901, 116.31}}, + {{39.9, 116.3}, {40.0, 116.31}}, + {{40.0, 116.3}, {39.9, 116.31}}}); + const double eps = query::endpoint_lower_bound(db.trajectory(0), db.trajectory(1)); + const auto workload = query::generate_self_join(db, eps); + EXPECT_EQ(workload.entries[0].candidates, (std::vector{1})); + EXPECT_EQ(workload.entries[0].reference, (std::vector{1})); +} + +TEST(SelfJoin, EmptyPairSetsAreUndefinedAndInvalidInputsAreRejected) { + for (const auto &db : + {make_db({}), make_db({{{39.9, 116.3}}}), make_db({{{39.9, 116.3}}, {{40.9, 116.3}}})}) { + const auto scores = evaluator::evaluate_join(db, query::generate_self_join(db)); + EXPECT_TRUE(std::isnan(scores.f1)); + EXPECT_TRUE(std::isnan(scores.precision)); + EXPECT_TRUE(std::isnan(scores.recall)); + } + const auto db = make_db({{{39.9, 116.3}}, {{39.9, 116.3}}}); + for (double eps : {0., -1., std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN()}) + EXPECT_THROW(query::generate_self_join(db, eps), std::invalid_argument); + EXPECT_THROW(query::generate_self_join(make_db({{}})), std::invalid_argument); + const auto workload = query::generate_self_join(db); + EXPECT_THROW(evaluator::evaluate_join(make_db({}), workload), std::invalid_argument); + EXPECT_THROW(evaluator::evaluate_join(make_db({{{40.0, 116.3}}, {{39.9, 116.3}}}), workload), + std::invalid_argument); + const std::vector reverse{1, 0}; + EXPECT_THROW(evaluator::evaluate_join(core::subset(db, reverse), workload), + std::invalid_argument); +} + +TEST(SelfJoin, EmptyReferenceWithFalsePositivesScoresZero) { + const auto db = make_db({{{39.9, 116.3}, {39.92, 116.3}, {39.9, 116.31}}, + {{39.9, 116.3}, {39.88, 116.3}, {39.9, 116.31}}}); + core::Simplification s; + const std::vector endpoints{0, 2}; + s.add_trajectory(endpoints); + s.add_trajectory(endpoints); + const auto scores = + evaluator::evaluate_join(core::materialize(db, s), query::generate_self_join(db)); + EXPECT_EQ(scores.reference_pairs, 0u); + EXPECT_EQ(scores.predicted_pairs, 1u); + EXPECT_DOUBLE_EQ(scores.f1, 0); + EXPECT_DOUBLE_EQ(scores.precision, 0); + EXPECT_TRUE(std::isnan(scores.recall)); +} diff --git a/engine/tests/range_test.cpp b/engine/tests/range_test.cpp index 793aaa8..c7596ea 100644 --- a/engine/tests/range_test.cpp +++ b/engine/tests/range_test.cpp @@ -230,6 +230,18 @@ TEST(RangeRecall, DroppedPointsInsideTheBoxLowerRecall) { const auto compressed = core::materialize(original, kept); const auto box = origin_box(0, 100); + const std::vector workload{{box, {0, 1}}}; + const auto scores = evaluator::evaluate_range(compressed, workload); + EXPECT_DOUBLE_EQ(scores.recall, 0.5); + EXPECT_DOUBLE_EQ(scores.f1, 2.0 / 3); EXPECT_DOUBLE_EQ( evaluator::range::recall(query::range(original, box), query::range(compressed, box)), 0.5); } + +TEST(RangeF1, ScoresMembershipIncludingEmptySetsAndFalsePositives) { + EXPECT_DOUBLE_EQ(evaluator::range::f1({1, 2, 3, 4}, {1, 2}), 2.0 / 3); + EXPECT_DOUBLE_EQ(evaluator::range::f1({1, 2}, {1, 3}), 0.5); + EXPECT_DOUBLE_EQ(evaluator::range::f1({1}, {}), 0); + EXPECT_DOUBLE_EQ(evaluator::range::f1({}, {1}), 0); + EXPECT_TRUE(std::isnan(evaluator::range::f1({}, {}))); +} From 641bd960097ddc18135fa985cd0a4bf9ed7fefda Mon Sep 17 00:00:00 2001 From: Kristofferjoha Date: Thu, 5 Nov 2026 19:46:47 +0100 Subject: [PATCH 2/2] fixed frutte format comment --- engine/src/evaluator/join.cpp | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/engine/src/evaluator/join.cpp b/engine/src/evaluator/join.cpp index 29fdd7c..6c1cbd5 100644 --- a/engine/src/evaluator/join.cpp +++ b/engine/src/evaluator/join.cpp @@ -53,7 +53,12 @@ JoinScores evaluate_join(const core::TrajectoryDb &compressed, const std::size_t n = workload.entries.size(); std::vector totals(n); const double nan = std::numeric_limits::quiet_NaN(); - std::vector f1(n, nan), precision(n, nan), recall(n, nan); + std::vector f1, precision, recall; + if (!self) { + f1.assign(n, nan); + precision.assign(n, nan); + recall.assign(n, nan); + } core::parallel_for(n, [&](std::size_t i) { const auto &entry = workload.entries[i]; @@ -71,6 +76,8 @@ JoinScores evaluate_join(const core::TrajectoryDb &compressed, } totals[i] = counts; + if (self) + return; const auto hits = static_cast(counts.hits); const auto found = static_cast(counts.found); const auto truth = static_cast(entry.reference.size()); @@ -82,9 +89,9 @@ JoinScores evaluate_join(const core::TrajectoryDb &compressed, f1[i] = 2.0 * hits / (found + truth); }); - JoinScores result{.f1 = average(f1), - .precision = average(precision), - .recall = average(recall), + JoinScores result{.f1 = self ? nan : average(f1), + .precision = self ? nan : average(precision), + .recall = self ? nan : average(recall), .eps_m = workload.eps_m, .query_count = self ? 0 : n, .mode = workload.mode};