yams/tests/unit/search/result_ranker_catch2_test.cpp

533 lines
23 KiB
C++

/**
* @file result_ranker_catch2_test.cpp
* @brief Tests for ResultRanker and AdvancedRanker (search/result_ranker.h)
*
* Covers: calculateTFIDF, calculateFreshnessScore, calculateSizeScore,
* calculatePositionScore, calculatePhraseBonus, calculateExactMatchBonus,
* extractQueryTerms, rankResults, AdvancedRanker algorithm selection.
*/
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <yams/search/query_ast.h>
#include <yams/search/result_ranker.h>
#include <yams/search/search_results.h>
#include <chrono>
#include <cmath>
#include <memory>
#include <string>
#include <vector>
using namespace yams::search;
using Catch::Approx;
using yams::DocumentId;
// ────────────────────────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────────────────────────
namespace {
/// Build a minimal SearchResultItem for scoring tests
SearchResultItem makeItem(float relevance = 0.0f, size_t fileSize = 10000,
std::chrono::system_clock::time_point lastMod = {},
float termFreq = 0.0f) {
SearchResultItem item;
item.documentId = DocumentId{1};
item.title = "test-doc";
item.path = "/tmp/test.txt";
item.contentType = "text/plain";
item.fileSize = fileSize;
item.lastModified = lastMod;
item.relevanceScore = relevance;
item.termFrequency = termFreq;
return item;
}
/// Build an item with title (for exact-match / phrase-match tests)
SearchResultItem makeItemWithTitle(const std::string& title, float relevance = 0.0f) {
SearchResultItem item;
item.documentId = DocumentId{1};
item.title = title;
item.relevanceScore = relevance;
item.fileSize = 5000;
return item;
}
/// Build a SearchHighlight for position-score tests
SearchHighlight makeHighlight(size_t startOffset, size_t endOffset) {
SearchHighlight h;
h.field = "content";
h.snippet = "...";
h.startOffset = startOffset;
h.endOffset = endOffset;
return h;
}
} // namespace
// ────────────────────────────────────────────────────────────────────────────────
// RankingConfig defaults
// ────────────────────────────────────────────────────────────────────────────────
TEST_CASE("RankingConfig has sensible defaults", "[search][ranker][catch2]") {
RankingConfig cfg;
CHECK(cfg.termFrequencyWeight == Approx(1.0f));
CHECK(cfg.inverseDocumentFrequencyWeight == Approx(1.0f));
CHECK(cfg.freshnessWeight == Approx(0.1f));
CHECK(cfg.maxFreshnessBoost == Approx(0.2f));
CHECK(cfg.sizeWeight == Approx(0.05f));
CHECK(cfg.optimalSize == 10000);
CHECK(cfg.phraseMatchBonus == Approx(0.3f));
CHECK(cfg.exactMatchBonus == Approx(0.5f));
CHECK(cfg.positionWeight == Approx(0.1f));
CHECK(cfg.sortLimit == 100);
CHECK(cfg.fieldBoosts.count("title") == 1);
CHECK(cfg.fieldBoosts.at("title") == Approx(2.0f));
CHECK(cfg.fieldBoosts.count("content") == 1);
CHECK(cfg.fieldBoosts.at("content") == Approx(1.0f));
}
// ────────────────────────────────────────────────────────────────────────────────
// CorpusStatistics::getIDF
// ────────────────────────────────────────────────────────────────────────────────
TEST_CASE("CorpusStatistics getIDF returns expected values", "[search][ranker][catch2]") {
CorpusStatistics stats;
stats.totalDocuments = 100;
stats.termDocumentFrequency["common"] = 50;
stats.termDocumentFrequency["rare"] = 2;
// IDF = ln(N / df)
float idfCommon = stats.getIDF("common");
float idfRare = stats.getIDF("rare");
CHECK(idfCommon == Approx(std::log(100.0f / 50.0f)));
CHECK(idfRare == Approx(std::log(100.0f / 2.0f)));
// Unknown term → 0.0
CHECK(stats.getIDF("unknown_term") == Approx(0.0f));
// Zero-frequency → 0.0
stats.termDocumentFrequency["zero"] = 0;
CHECK(stats.getIDF("zero") == Approx(0.0f));
}
// ────────────────────────────────────────────────────────────────────────────────
// ResultRanker — construction & config
// ────────────────────────────────────────────────────────────────────────────────
TEST_CASE("ResultRanker default construction", "[search][ranker][catch2]") {
ResultRanker ranker;
CHECK(ranker.getConfig().termFrequencyWeight == Approx(1.0f));
}
TEST_CASE("ResultRanker setConfig / getConfig round-trip", "[search][ranker][catch2]") {
ResultRanker ranker;
RankingConfig cfg;
cfg.termFrequencyWeight = 2.5f;
cfg.optimalSize = 20000;
ranker.setConfig(cfg);
CHECK(ranker.getConfig().termFrequencyWeight == Approx(2.5f));
CHECK(ranker.getConfig().optimalSize == 20000);
}
// ────────────────────────────────────────────────────────────────────────────────
// calculateTFIDF
// ────────────────────────────────────────────────────────────────────────────────
TEST_CASE("calculateTFIDF without corpus stats returns weighted TF", "[search][ranker][catch2]") {
ResultRanker ranker;
// Without corpus stats the IDF factor should be 0 for unknown terms,
// so the score depends on how the implementation handles no-IDF.
float score = ranker.calculateTFIDF("hello", 3.0f, "content");
// Score should be non-negative
CHECK(score >= 0.0f);
}
TEST_CASE("calculateTFIDF with corpus stats factors IDF", "[search][ranker][catch2]") {
ResultRanker ranker;
CorpusStatistics stats;
stats.totalDocuments = 1000;
stats.termDocumentFrequency["hello"] = 10;
ranker.setCorpusStatistics(stats);
float score = ranker.calculateTFIDF("hello", 5.0f, "content");
CHECK(score > 0.0f);
// Field boost: title should give higher score than content for same TF
float titleScore = ranker.calculateTFIDF("hello", 5.0f, "title");
float contentScore = ranker.calculateTFIDF("hello", 5.0f, "content");
CHECK(titleScore >= contentScore); // title boost = 2.0
}
TEST_CASE("calculateTFIDF unknown field defaults to boost 1.0", "[search][ranker][catch2]") {
ResultRanker ranker;
CorpusStatistics stats;
stats.totalDocuments = 100;
stats.termDocumentFrequency["term"] = 10;
ranker.setCorpusStatistics(stats);
float knownField = ranker.calculateTFIDF("term", 1.0f, "content"); // boost 1.0
float unknownField = ranker.calculateTFIDF("term", 1.0f, "foobar"); // boost 1.0 (default)
CHECK(knownField == Approx(unknownField));
}
// ────────────────────────────────────────────────────────────────────────────────
// calculateFreshnessScore
// ────────────────────────────────────────────────────────────────────────────────
TEST_CASE("calculateFreshnessScore: recent document scores higher", "[search][ranker][catch2]") {
ResultRanker ranker;
auto now = std::chrono::system_clock::now();
auto recent = now - std::chrono::hours(1);
auto old = now - std::chrono::hours(24 * 365);
float recentScore = ranker.calculateFreshnessScore(recent);
float oldScore = ranker.calculateFreshnessScore(old);
CHECK(recentScore >= 0.0f);
CHECK(oldScore >= 0.0f);
CHECK(recentScore >= oldScore);
}
TEST_CASE("calculateFreshnessScore: default time_point", "[search][ranker][catch2]") {
ResultRanker ranker;
// Epoch (default-constructed time_point) should be very old → low score
std::chrono::system_clock::time_point epoch{};
float score = ranker.calculateFreshnessScore(epoch);
CHECK(score >= 0.0f);
CHECK(score <= ranker.getConfig().maxFreshnessBoost + 0.01f);
}
// ────────────────────────────────────────────────────────────────────────────────
// calculateSizeScore
// ────────────────────────────────────────────────────────────────────────────────
TEST_CASE("calculateSizeScore: optimal size gets highest score", "[search][ranker][catch2]") {
RankingConfig cfg;
cfg.optimalSize = 10000;
ResultRanker ranker(cfg);
float optimalScore = ranker.calculateSizeScore(10000);
float smallScore = ranker.calculateSizeScore(100);
float largeScore = ranker.calculateSizeScore(1000000);
CHECK(optimalScore >= smallScore);
CHECK(optimalScore >= largeScore);
}
TEST_CASE("calculateSizeScore: zero file size", "[search][ranker][catch2]") {
ResultRanker ranker;
float score = ranker.calculateSizeScore(0);
CHECK(score >= 0.0f);
}
// ────────────────────────────────────────────────────────────────────────────────
// calculatePositionScore
// ────────────────────────────────────────────────────────────────────────────────
TEST_CASE("calculatePositionScore: empty highlights", "[search][ranker][catch2]") {
ResultRanker ranker;
std::vector<SearchHighlight> empty;
float score = ranker.calculatePositionScore(empty);
CHECK(score >= 0.0f);
}
TEST_CASE("calculatePositionScore: earlier matches score higher", "[search][ranker][catch2]") {
ResultRanker ranker;
std::vector<SearchHighlight> earlyMatch = {makeHighlight(10, 20)};
std::vector<SearchHighlight> lateMatch = {makeHighlight(50000, 50010)};
float earlyScore = ranker.calculatePositionScore(earlyMatch);
float lateScore = ranker.calculatePositionScore(lateMatch);
CHECK(earlyScore >= lateScore);
}
// ────────────────────────────────────────────────────────────────────────────────
// extractQueryTerms
// ────────────────────────────────────────────────────────────────────────────────
TEST_CASE("extractQueryTerms from TermNode", "[search][ranker][catch2]") {
ResultRanker ranker;
TermNode term("hello");
auto terms = ranker.extractQueryTerms(&term);
REQUIRE(terms.size() >= 1);
CHECK(terms[0] == "hello");
}
TEST_CASE("extractQueryTerms from PhraseNode", "[search][ranker][catch2]") {
ResultRanker ranker;
PhraseNode phrase("hello world");
auto terms = ranker.extractQueryTerms(&phrase);
// Should contain "hello world" or decomposed terms
REQUIRE(!terms.empty());
}
TEST_CASE("extractQueryTerms from nullptr returns empty", "[search][ranker][catch2]") {
ResultRanker ranker;
auto terms = ranker.extractQueryTerms(nullptr);
CHECK(terms.empty());
}
TEST_CASE("extractQueryTerms from AndNode extracts from both sides", "[search][ranker][catch2]") {
ResultRanker ranker;
auto left = std::make_unique<TermNode>("alpha");
auto right = std::make_unique<TermNode>("beta");
AndNode andNode(std::move(left), std::move(right));
auto terms = ranker.extractQueryTerms(&andNode);
REQUIRE(terms.size() >= 2);
// Both terms should be present (order may vary)
bool hasAlpha = false, hasBeta = false;
for (const auto& t : terms) {
if (t == "alpha")
hasAlpha = true;
if (t == "beta")
hasBeta = true;
}
CHECK(hasAlpha);
CHECK(hasBeta);
}
// ────────────────────────────────────────────────────────────────────────────────
// calculatePhraseBonus / calculateExactMatchBonus
// ────────────────────────────────────────────────────────────────────────────────
TEST_CASE("calculatePhraseBonus: phrase query on matching title", "[search][ranker][catch2]") {
ResultRanker ranker;
PhraseNode phrase("test document");
auto item = makeItemWithTitle("test document about things");
float bonus = ranker.calculatePhraseBonus(item, &phrase);
CHECK(bonus >= 0.0f);
}
TEST_CASE("calculatePhraseBonus: nullptr query", "[search][ranker][catch2]") {
ResultRanker ranker;
auto item = makeItemWithTitle("test");
float bonus = ranker.calculatePhraseBonus(item, nullptr);
CHECK(bonus >= 0.0f);
}
TEST_CASE("calculateExactMatchBonus: exact title match", "[search][ranker][catch2]") {
ResultRanker ranker;
TermNode term("test-doc");
auto item = makeItemWithTitle("test-doc");
float bonus = ranker.calculateExactMatchBonus(item, &term);
CHECK(bonus >= 0.0f);
}
TEST_CASE("calculateExactMatchBonus: nullptr query", "[search][ranker][catch2]") {
ResultRanker ranker;
auto item = makeItemWithTitle("test");
float bonus = ranker.calculateExactMatchBonus(item, nullptr);
CHECK(bonus >= 0.0f);
}
// ────────────────────────────────────────────────────────────────────────────────
// calculateScore
// ────────────────────────────────────────────────────────────────────────────────
TEST_CASE("calculateScore produces non-negative result", "[search][ranker][catch2]") {
ResultRanker ranker;
TermNode term("hello");
auto item = makeItem(0.5f, 10000, std::chrono::system_clock::now(), 3.0f);
float score = ranker.calculateScore(item, &term);
CHECK(score >= 0.0f);
}
TEST_CASE("calculateScore with nullptr query", "[search][ranker][catch2]") {
ResultRanker ranker;
auto item = makeItem();
float score = ranker.calculateScore(item, nullptr);
CHECK(score >= 0.0f);
}
// ────────────────────────────────────────────────────────────────────────────────
// rankResults
// ────────────────────────────────────────────────────────────────────────────────
TEST_CASE("rankResults sorts items by score descending", "[search][ranker][catch2]") {
ResultRanker ranker;
CorpusStatistics stats;
stats.totalDocuments = 100;
stats.termDocumentFrequency["important"] = 5;
ranker.setCorpusStatistics(stats);
TermNode term("important");
auto now = std::chrono::system_clock::now();
std::vector<SearchResultItem> items;
// Items with different term frequencies should rank differently
auto item1 = makeItem(0.2f, 10000, now, 1.0f);
item1.title = "important document";
auto item2 = makeItem(0.8f, 10000, now, 5.0f);
item2.title = "very important reference";
auto item3 = makeItem(0.1f, 10000, now, 0.1f);
item3.title = "unrelated text";
items.push_back(item1);
items.push_back(item2);
items.push_back(item3);
ranker.rankResults(items, &term);
// After ranking, scores should be in descending order
for (size_t i = 1; i < items.size(); ++i) {
CHECK(items[i - 1].relevanceScore >= items[i].relevanceScore);
}
}
TEST_CASE("rankResults empty vector is no-op", "[search][ranker][catch2]") {
ResultRanker ranker;
TermNode term("test");
std::vector<SearchResultItem> empty;
ranker.rankResults(empty, &term);
CHECK(empty.empty());
}
TEST_CASE("rankResults single item", "[search][ranker][catch2]") {
ResultRanker ranker;
TermNode term("test");
std::vector<SearchResultItem> items = {makeItem(0.5f)};
ranker.rankResults(items, &term);
CHECK(items.size() == 1);
CHECK(items[0].relevanceScore >= 0.0f);
}
// ────────────────────────────────────────────────────────────────────────────────
// AdvancedRanker
// ────────────────────────────────────────────────────────────────────────────────
TEST_CASE("AdvancedRanker default uses Hybrid algorithm", "[search][ranker][catch2]") {
AdvancedRanker ranker;
CHECK(ranker.getAlgorithm() == RankingAlgorithm::Hybrid);
}
TEST_CASE("AdvancedRanker setAlgorithm / getAlgorithm", "[search][ranker][catch2]") {
AdvancedRanker ranker;
ranker.setAlgorithm(RankingAlgorithm::BM25);
CHECK(ranker.getAlgorithm() == RankingAlgorithm::BM25);
ranker.setAlgorithm(RankingAlgorithm::Cosine);
CHECK(ranker.getAlgorithm() == RankingAlgorithm::Cosine);
ranker.setAlgorithm(RankingAlgorithm::TfIdf);
CHECK(ranker.getAlgorithm() == RankingAlgorithm::TfIdf);
}
TEST_CASE("AdvancedRanker explicit construction with algorithm", "[search][ranker][catch2]") {
AdvancedRanker ranker(RankingAlgorithm::BM25);
CHECK(ranker.getAlgorithm() == RankingAlgorithm::BM25);
}
TEST_CASE("AdvancedRanker getBaseRanker access", "[search][ranker][catch2]") {
AdvancedRanker ranker;
// Mutable access
RankingConfig cfg;
cfg.optimalSize = 42000;
ranker.getBaseRanker().setConfig(cfg);
CHECK(ranker.getBaseRanker().getConfig().optimalSize == 42000);
// Const access
const auto& constRanker = ranker;
CHECK(constRanker.getBaseRanker().getConfig().optimalSize == 42000);
}
TEST_CASE("AdvancedRanker rankResults with each algorithm", "[search][ranker][catch2]") {
auto now = std::chrono::system_clock::now();
TermNode term("test");
auto makeItems = [&]() {
std::vector<SearchResultItem> items;
auto item1 = makeItem(0.8f, 5000, now, 4.0f);
item1.title = "test alpha";
auto item2 = makeItem(0.3f, 15000, now, 1.0f);
item2.title = "test beta";
items.push_back(item1);
items.push_back(item2);
return items;
};
SECTION("TfIdf") {
AdvancedRanker ranker(RankingAlgorithm::TfIdf);
auto items = makeItems();
ranker.rankResults(items, &term);
CHECK(items[0].relevanceScore >= items[1].relevanceScore);
}
SECTION("BM25") {
AdvancedRanker ranker(RankingAlgorithm::BM25);
auto items = makeItems();
ranker.rankResults(items, &term);
CHECK(items[0].relevanceScore >= items[1].relevanceScore);
}
SECTION("Cosine") {
AdvancedRanker ranker(RankingAlgorithm::Cosine);
auto items = makeItems();
ranker.rankResults(items, &term);
CHECK(items[0].relevanceScore >= items[1].relevanceScore);
}
SECTION("Hybrid") {
AdvancedRanker ranker(RankingAlgorithm::Hybrid);
auto items = makeItems();
ranker.rankResults(items, &term);
CHECK(items[0].relevanceScore >= items[1].relevanceScore);
}
}
TEST_CASE("AdvancedRanker rankResults with empty vector", "[search][ranker][catch2]") {
AdvancedRanker ranker;
TermNode term("test");
std::vector<SearchResultItem> empty;
ranker.rankResults(empty, &term);
CHECK(empty.empty());
}
// ────────────────────────────────────────────────────────────────────────────────
// RankingConfig sortLimit exercises partial_sort path
// ────────────────────────────────────────────────────────────────────────────────
TEST_CASE("rankResults with sortLimit exercises partial_sort path", "[search][ranker][catch2]") {
RankingConfig cfg;
cfg.sortLimit = 5; // Force partial_sort for > 5 items
ResultRanker ranker(cfg);
TermNode term("doc");
auto now = std::chrono::system_clock::now();
std::vector<SearchResultItem> items;
for (int i = 0; i < 20; ++i) {
auto item =
makeItem(static_cast<float>(i) * 0.05f, 10000, now, static_cast<float>(i) * 0.5f);
item.title = "doc " + std::to_string(i);
items.push_back(item);
}
ranker.rankResults(items, &term);
// Top 5 should be sorted descending
for (size_t i = 1; i < 5 && i < items.size(); ++i) {
CHECK(items[i - 1].relevanceScore >= items[i].relevanceScore);
}
}