* 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>
145 lines
5.4 KiB
C++
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));
|
|
}
|