yams/tests/unit/search/final_result_order_catch2_test.cpp
Trevon 3272367ea2
fix(search): restore measured scientific ranking (#318)
* fix(search): make the final lexical tie-break a strict weak ordering

The final comparator broke near-ties toward the result with less vector
support, and its pairwise |a - b| <= epsilon test was not transitive, which
is undefined behavior for the std::sort/partial_sort callers in
applySearchTopKLimit.

Compare tie buckets floor(score / epsilon) instead, then lexical anchor,
keyword and vector evidence (more first), then doc id. Behavior with the
tie-break off is unchanged. The comparator moves to
final_result_order_internal.h so it can be tested directly.

Signed-off-by: trvon <me@trevon.dev>
(cherry picked from commit ca9b82d6ce9db31b6c9608a7d95b63203649e5a7)
Signed-off-by: trvon <git@trevon.dev>

* fix(search): keep cross-reranked scores above the unreranked tail

applyCrossRerank rewrote the window's scores to a 0..1 min-max blend while
the tail kept raw fused scores. Later whole-list sorts (concept boost,
top-k partial_sort) compared the two scales, so reranked docs could drop
below tail docs or out of the top-k.

Map the blended score back onto the window's original range, with the
floor raised just above the best tail score when needed. The window's
reranked order is unchanged and docTraces.finalScore records the mapped
score.

Signed-off-by: trvon <me@trevon.dev>
(cherry picked from commit d66c6d304ca4d32b943e164b0d7720fc7f865db1)
Signed-off-by: trvon <git@trevon.dev>

* fix(search): judge weak tier-1 queries on the raw bm25 scale

The weak-query test compared the top Text/SimeonText component score with
weakQueryMinTopTextScore, but lexical batches are min-max normalized, so the
top hit is always 1.0. With enough hits every query was strong: the
weak-query vector fanout boost and weak-query topology expansion never ran.

scoreLexicalBatch now also records absoluteLexicalScore, the hit's score on
the existing absolute scale (-bm25 / bm25NormDivisor, same multipliers),
and classifyTier1TextSignal uses it. Thresholds keep their values; with the
default divisor 25, 0.30 is bm25 <= -7.5 and 0.45 is bm25 <= -11.25.

Signed-off-by: trvon <me@trevon.dev>
(cherry picked from commit bc771c5aab1721170fb9e743013ca0efbf048d61)
Signed-off-by: trvon <git@trevon.dev>

* fix(search): restore measured scientific fusion

Give SCIENTIFIC the measured relative Simeon weight of 0.40 after normalizing the existing text/vector pair. The resulting share is 0.285714, matching the benchmark traces. Other profiles remain unchanged.

Keep its one-slot semantic rescue preset instead of raising it to three at the corpus layer. Preserve the existing large-prose recall widening. Cover the preset weights, config propagation, community blend, rich-graph ratios and corpus rescue behavior.

Signed-off-by: trvon <git@trevon.dev>

* fix(search): order non-finite ranking evidence

Treat NaN score/evidence keys as negative infinity and invalid tie epsilon as zero. This keeps comparator equivalence transitive without changing finite-input ordering. Add exhaustive triples covering NaN, infinities and invalid epsilon.

Signed-off-by: trvon <git@trevon.dev>

* docs(search): clarify lexical scoring limits

Distinguish the shared single-hit score conversion from FTS5 BM25 calibration. Note that lexical tie buckets can still reorder numerically separated rerank-window and tail scores. No behavior change.

Signed-off-by: trvon <git@trevon.dev>

---------

Signed-off-by: trvon <me@trevon.dev>
Signed-off-by: trvon <git@trevon.dev>
2026-10-16 09:43:41 -06:00

145 lines
5.4 KiB
C++

// Copyright 2025 The YAMS Authors
// SPDX-License-Identifier: GPL-3.0-or-later
#include <catch2/catch_test_macros.hpp>
#include "src/search/final_result_order_internal.h"
#include <cstddef>
#include <limits>
#include <random>
#include <string>
#include <vector>
using yams::search::SearchResult;
using yams::search::detail::finalLexicalAwareLess;
namespace {
SearchResult makeResult(std::string id, float score, double keyword, double vector) {
SearchResult r;
r.document.sha256Hash = id;
r.document.filePath = "/x/" + id;
r.score = score;
r.keywordScore = keyword;
r.vectorScore = vector;
return r;
}
} // namespace
TEST_CASE("final lexical tie-break prefers more vector evidence", "[search][order][catch2]") {
// Same tie bucket, same lexical and keyword evidence; only vector support differs. The doc
// ids put the low-vector doc first so the docId fallback cannot mask the vector decision.
const auto lowVector = makeResult("a", 0.512F, 0.4, 0.1);
const auto highVector = makeResult("b", 0.514F, 0.4, 0.8);
CHECK(finalLexicalAwareLess(highVector, lowVector, true, 0.01F));
CHECK_FALSE(finalLexicalAwareLess(lowVector, highVector, true, 0.01F));
}
TEST_CASE("final lexical tie-break comparator is a strict weak ordering",
"[search][order][catch2]") {
std::mt19937 rng(20261015U);
// Scores clustered around a few centers so many pairs fall inside the tie epsilon.
std::uniform_real_distribution<float> centerJitter(-0.02F, 0.02F);
std::uniform_int_distribution<int> center(0, 4);
std::uniform_int_distribution<int> evidence(0, 3);
std::vector<SearchResult> results;
for (int i = 0; i < 300; ++i) {
const float score = 0.2F + 0.05F * static_cast<float>(center(rng)) + centerJitter(rng);
results.push_back(
makeResult("d" + std::to_string(i), score, 0.1 * evidence(rng), 0.1 * evidence(rng)));
}
const float epsilon = 0.01F;
const auto less = [epsilon](const SearchResult& a, const SearchResult& b) {
return finalLexicalAwareLess(a, b, true, epsilon);
};
std::size_t asymmetryViolations = 0;
std::size_t irreflexivityViolations = 0;
for (const auto& a : results) {
if (less(a, a)) {
++irreflexivityViolations;
}
for (const auto& b : results) {
if (less(a, b) && less(b, a)) {
++asymmetryViolations;
}
}
}
CHECK(irreflexivityViolations == 0);
CHECK(asymmetryViolations == 0);
// Transitivity of the order and of incomparability over sampled triples.
std::uniform_int_distribution<std::size_t> pick(0, results.size() - 1);
std::size_t transitivityViolations = 0;
for (int t = 0; t < 200000; ++t) {
const auto& a = results[pick(rng)];
const auto& b = results[pick(rng)];
const auto& c = results[pick(rng)];
if (less(a, b) && less(b, c) && !less(a, c)) {
++transitivityViolations;
}
const bool abEquiv = !less(a, b) && !less(b, a);
const bool bcEquiv = !less(b, c) && !less(c, b);
const bool acEquiv = !less(a, c) && !less(c, a);
if (abEquiv && bcEquiv && !acEquiv) {
++transitivityViolations;
}
}
CHECK(transitivityViolations == 0);
}
TEST_CASE("final comparator orders non-finite scores and evidence deterministically",
"[search][order][catch2]") {
const double nan = std::numeric_limits<double>::quiet_NaN();
const float inf = std::numeric_limits<float>::infinity();
std::vector<SearchResult> results = {
makeResult("nan", std::numeric_limits<float>::quiet_NaN(), 0.0, 0.0),
makeResult("low", 0.2F, 0.0, 0.0),
makeResult("high", 0.8F, 0.0, 0.0),
makeResult("inf", inf, 0.0, 0.0),
makeResult("negative_inf", -inf, 0.0, 0.0),
makeResult("nan_keyword", 0.2F, nan, 0.0),
makeResult("nan_vector", 0.2F, 0.0, nan),
};
for (bool enabled : {false, true}) {
for (float epsilon : {0.0F, 0.01F, inf}) {
const auto less = [=](const SearchResult& a, const SearchResult& b) {
return finalLexicalAwareLess(a, b, enabled, epsilon);
};
CAPTURE(enabled, epsilon);
CHECK(less(results[1], results[0]));
CHECK_FALSE(less(results[0], results[1]));
for (const auto& a : results) {
CHECK_FALSE(less(a, a));
for (const auto& b : results) {
CHECK_FALSE((less(a, b) && less(b, a)));
for (const auto& c : results) {
if (less(a, b) && less(b, c)) {
CHECK(less(a, c));
}
if (!less(a, b) && !less(b, a) && !less(b, c) && !less(c, b)) {
CHECK_FALSE((less(a, c) || less(c, a)));
}
}
}
}
}
}
}
TEST_CASE("final comparator without tie-break orders by score then doc id",
"[search][order][catch2]") {
const auto a = makeResult("a", 0.512F, 0.0, 0.9);
const auto b = makeResult("b", 0.514F, 0.9, 0.0);
const auto c = makeResult("c", 0.514F, 0.0, 0.0);
CHECK(finalLexicalAwareLess(b, a, false, 0.01F));
CHECK_FALSE(finalLexicalAwareLess(a, b, false, 0.01F));
CHECK(finalLexicalAwareLess(b, c, false, 0.01F)); // equal score -> doc id
CHECK_FALSE(finalLexicalAwareLess(c, b, false, 0.01F));
}