yams/tests/unit/search/graph_expansion_catch2_test.cpp
Trevon 864f677269
[09/14] Bound rerank text and reuse search parsing (#149)
* 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>
2026-09-12 10:09:20 -04:00

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());
}
}
}