* perf(search): preview-based rerank text and compiled path globs Cross-rerank built each window entry's text with a full getContent() read, then kept only the first rerankSnippetMaxChars characters. The window now hydrates with one batchGetContentPreview() call bounded to that many characters; buildCrossRerankText takes the preview instead of the repository. batchGetContentPreview is virtual so a test can count it: three window results cost one preview batch and zero full reads. Path filtering compiled a std::regex from the glob on every call, and the call sits inside per-document loops in pathSearch, the hash-prefix search, and metadata filtering. GlobMatcher (src/app/services) holds the compiled form; wildcardMatch() reuses it through a small per-thread cache. The glob semantics are unchanged and now pinned by a test: '*' stays within one path segment, '**' crosses, '?' is one character, regex metacharacters are literal. yams::common::wildcard_match was considered and rejected because its '*' crosses separators. portability_allowlist.txt: two search_service.cpp anchors reflowed. Signed-off-by: trvon <me@trevon.dev> * perf(search): parse edge anchor properties once, lower the path query once Graph expansion parsed every visited edge's JSON properties with nlohmann::json::parse to read at most two strings, region and scope, in three places (relation weighting, seed anchoring, and neighbour anchoring). Properties strings repeat heavily across edges and most carry no anchor at all. edgeAnchorHints() now returns immediately when the text cannot contain either key and otherwise parses each distinct string once per thread through a small bounded cache. A process-wide parse counter lets the test pin it: one hundred lookups of the same anchored string cost one parse, and un-anchored or malformed strings cost none. computePathMatchScore lowercased and trimmed the query for every candidate document; pathSearch now does that once and passes the lowered query down. The existing path-search tests cover the scoring. portability_allowlist.txt: two search_service.cpp anchors reflowed. edgeAnchorHints returns its two short strings by value: the per-thread memo clears itself at 1024 entries, so a reference into it could dangle across calls even though today's three callers consume the result immediately. Signed-off-by: trvon <me@trevon.dev> * fix(search): preserve hydration failure semantics Stop reranking before scorer calls or score changes when previews fail. Commit anchor hints atomically and recognize escaped JSON keys. Signed-off-by: trvon <me@trevon.dev> --------- Signed-off-by: trvon <me@trevon.dev>
232 lines
8.6 KiB
C++
232 lines
8.6 KiB
C++
#include <chrono>
|
|
#include <filesystem>
|
|
#include <unordered_map>
|
|
#include <unordered_set>
|
|
|
|
#include <catch2/catch_test_macros.hpp>
|
|
|
|
#include <yams/metadata/knowledge_graph_store.h>
|
|
#include <yams/search/graph_expansion.h>
|
|
|
|
using namespace yams;
|
|
using namespace yams::metadata;
|
|
using namespace yams::search;
|
|
|
|
namespace {
|
|
|
|
std::filesystem::path tempDbPath(const char* prefix) {
|
|
const char* t = std::getenv("YAMS_TEST_TMPDIR");
|
|
auto base = (t && *t) ? std::filesystem::path(t) : std::filesystem::temp_directory_path();
|
|
std::error_code ec;
|
|
std::filesystem::create_directories(base, ec);
|
|
auto ts = std::chrono::steady_clock::now().time_since_epoch().count();
|
|
auto p = base / (std::string(prefix) + std::to_string(ts) + ".db");
|
|
std::filesystem::remove(p, ec);
|
|
return p;
|
|
}
|
|
|
|
struct GraphExpansionFixture {
|
|
GraphExpansionFixture() {
|
|
dbPath = tempDbPath("graph_expansion_catch2_test_");
|
|
KnowledgeGraphStoreConfig cfg{};
|
|
auto storeRes = makeSqliteKnowledgeGraphStore(dbPath.string(), cfg);
|
|
REQUIRE(storeRes.has_value());
|
|
store = std::shared_ptr<KnowledgeGraphStore>(std::move(storeRes.value()));
|
|
REQUIRE(store != nullptr);
|
|
}
|
|
|
|
~GraphExpansionFixture() {
|
|
store.reset();
|
|
std::error_code ec;
|
|
std::filesystem::remove(dbPath, ec);
|
|
}
|
|
|
|
std::shared_ptr<KnowledgeGraphStore> store;
|
|
std::filesystem::path dbPath;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("Graph expansion ignores path and blob nodes", "[search][graph-expansion]") {
|
|
GraphExpansionFixture fixture;
|
|
|
|
std::vector<KGNode> nodes = {
|
|
KGNode{.nodeKey = "path:logical:/corpus/prostate_cancer.txt",
|
|
.label = std::string("prostate cancer"),
|
|
.type = std::string("path")},
|
|
KGNode{.nodeKey = "blob:abc123",
|
|
.label = std::string("prostate cancer"),
|
|
.type = std::string("blob")},
|
|
};
|
|
auto ids = fixture.store->upsertNodes(nodes);
|
|
REQUIRE(ids.has_value());
|
|
|
|
auto terms = generateGraphExpansionTerms(
|
|
fixture.store, "prostate cancer", {},
|
|
GraphExpansionConfig{.maxTerms = 8, .maxSeeds = 6, .maxNeighbors = 6});
|
|
|
|
CHECK(terms.empty());
|
|
}
|
|
|
|
TEST_CASE("Graph expansion ignores text segment nodes", "[search][graph-expansion]") {
|
|
CHECK(graphNodeExpansionWeight(std::optional<std::string>{"text_segment"},
|
|
"long abstract-like segment") == 0.0f);
|
|
}
|
|
|
|
TEST_CASE("Graph expansion suppresses low-signal alias sources", "[search][graph-expansion]") {
|
|
GraphExpansionFixture fixture;
|
|
|
|
KGNode node{.nodeKey = "nl_entity:disease:chronic disease",
|
|
.label = std::string("chronic disease"),
|
|
.type = std::string("disease")};
|
|
auto nodeId = fixture.store->upsertNode(node);
|
|
REQUIRE(nodeId.has_value());
|
|
|
|
REQUIRE(fixture.store
|
|
->addAliases({KGAlias{.nodeId = nodeId.value(),
|
|
.alias = std::string("chronic disease"),
|
|
.source = std::string("gliner.surface"),
|
|
.confidence = 1.0f},
|
|
KGAlias{.nodeId = nodeId.value(),
|
|
.alias = std::string("organization chronic disease"),
|
|
.source = std::string("gliner.type_qualified"),
|
|
.confidence = 0.95f}})
|
|
.has_value());
|
|
|
|
auto terms = generateGraphExpansionTerms(
|
|
fixture.store, "chronic disease", {},
|
|
GraphExpansionConfig{.maxTerms = 8, .maxSeeds = 6, .maxNeighbors = 6});
|
|
|
|
bool sawSurface = false;
|
|
bool sawTypeQualified = false;
|
|
for (const auto& term : terms) {
|
|
if (term.text == "chronic disease") {
|
|
sawSurface = true;
|
|
}
|
|
if (term.text == "organization chronic disease") {
|
|
sawTypeQualified = true;
|
|
}
|
|
}
|
|
CHECK(sawSurface);
|
|
CHECK_FALSE(sawTypeQualified);
|
|
}
|
|
|
|
// --- Graph expansion term dedup tests ---
|
|
|
|
TEST_CASE("Graph expansion term merge: max-score-wins for overlapping terms",
|
|
"[search][graph-expansion][dedup]") {
|
|
// Simulate the merge logic from search_engine.cpp
|
|
std::vector<GraphExpansionTerm> existing = {
|
|
{.text = "cancer", .score = 0.8f},
|
|
{.text = "treatment", .score = 0.5f},
|
|
{.text = "unique_a", .score = 0.3f},
|
|
};
|
|
|
|
std::vector<GraphExpansionTerm> incoming = {
|
|
{.text = "cancer", .score = 0.6f}, // overlap, lower score
|
|
{.text = "treatment", .score = 0.9f}, // overlap, higher score
|
|
{.text = "unique_b", .score = 0.4f}, // new term
|
|
};
|
|
|
|
// O(n) merge using unordered_map (the optimized approach)
|
|
std::unordered_map<std::string, size_t> termIndex;
|
|
termIndex.reserve(existing.size());
|
|
for (size_t i = 0; i < existing.size(); ++i) {
|
|
termIndex[existing[i].text] = i;
|
|
}
|
|
for (const auto& term : incoming) {
|
|
if (auto it = termIndex.find(term.text); it != termIndex.end()) {
|
|
existing[it->second].score = std::max(existing[it->second].score, term.score);
|
|
} else {
|
|
termIndex[term.text] = existing.size();
|
|
existing.push_back(term);
|
|
}
|
|
}
|
|
|
|
// Verify max-score-wins
|
|
std::unordered_map<std::string, float> scoreMap;
|
|
for (const auto& t : existing) {
|
|
scoreMap[t.text] = t.score;
|
|
}
|
|
CHECK(scoreMap.at("cancer") == 0.8f); // kept higher existing score
|
|
CHECK(scoreMap.at("treatment") == 0.9f); // took higher incoming score
|
|
CHECK(scoreMap.at("unique_a") == 0.3f); // preserved
|
|
CHECK(scoreMap.at("unique_b") == 0.4f); // added
|
|
}
|
|
|
|
TEST_CASE("Graph expansion term merge: no duplicates in result",
|
|
"[search][graph-expansion][dedup]") {
|
|
std::vector<GraphExpansionTerm> existing = {
|
|
{.text = "alpha", .score = 1.0f},
|
|
{.text = "beta", .score = 0.8f},
|
|
};
|
|
|
|
std::vector<GraphExpansionTerm> incoming = {
|
|
{.text = "alpha", .score = 0.5f},
|
|
{.text = "beta", .score = 0.3f},
|
|
{.text = "gamma", .score = 0.7f},
|
|
};
|
|
|
|
std::unordered_map<std::string, size_t> termIndex;
|
|
termIndex.reserve(existing.size());
|
|
for (size_t i = 0; i < existing.size(); ++i) {
|
|
termIndex[existing[i].text] = i;
|
|
}
|
|
for (const auto& term : incoming) {
|
|
if (auto it = termIndex.find(term.text); it != termIndex.end()) {
|
|
existing[it->second].score = std::max(existing[it->second].score, term.score);
|
|
} else {
|
|
termIndex[term.text] = existing.size();
|
|
existing.push_back(term);
|
|
}
|
|
}
|
|
|
|
// No duplicates
|
|
std::unordered_set<std::string> seen;
|
|
for (const auto& t : existing) {
|
|
CHECK(seen.insert(t.text).second); // insert returns false if already present
|
|
}
|
|
CHECK(existing.size() == 3); // alpha, beta, gamma
|
|
}
|
|
|
|
TEST_CASE("Graph expansion parses edge anchor properties once per distinct string",
|
|
"[search][graph-expansion][anchors]") {
|
|
const auto before = edgeAnchorParseCount();
|
|
const std::optional<std::string> anchored{R"({"region":"body_claim","scope":"body_segment"})"};
|
|
for (int i = 0; i < 100; ++i) {
|
|
const auto& hints = edgeAnchorHints(anchored);
|
|
CHECK(hints.region == "body_claim");
|
|
CHECK(hints.scope == "body_segment");
|
|
}
|
|
CHECK(edgeAnchorParseCount() - before == 1);
|
|
|
|
// Properties that cannot carry an anchor are never parsed at all.
|
|
const auto mid = edgeAnchorParseCount();
|
|
const std::optional<std::string> plain{R"({"weight":0.5,"offset":42})"};
|
|
CHECK(edgeAnchorHints(plain).region.empty());
|
|
CHECK(edgeAnchorHints(std::nullopt).scope.empty());
|
|
CHECK(edgeAnchorParseCount() == mid);
|
|
|
|
const auto retained = edgeAnchorHints(anchored);
|
|
for (int i = 0; i < 1025; ++i) {
|
|
auto hints = edgeAnchorHints(std::string{"{\"region\":\"title\",\"nonce\":"} +
|
|
std::to_string(i) + "}");
|
|
CHECK(hints.region == "title");
|
|
}
|
|
CHECK(retained.region == "body_claim");
|
|
CHECK(edgeAnchorHints(anchored).scope == "body_segment");
|
|
|
|
// Malformed JSON degrades to no hints instead of throwing.
|
|
const std::optional<std::string> broken{"{region: nope"};
|
|
CHECK(edgeAnchorHints(broken).region.empty());
|
|
CHECK(edgeAnchorHints(std::string{R"({"reg\u0069on":"title"})"}).region == "title");
|
|
// A wrong-type second field must not leave the first field committed in the cache.
|
|
for (const std::string invalid :
|
|
{R"({"region":"body_claim","scope":42})", R"({"region":false,"scope":"title"})"}) {
|
|
for (int repeat = 0; repeat < 2; ++repeat) {
|
|
auto hints = edgeAnchorHints(invalid);
|
|
CHECK(hints.region.empty());
|
|
CHECK(hints.scope.empty());
|
|
}
|
|
}
|
|
}
|