#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "tests/common/test_helpers_catch2.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; } class FixedEmbeddingBackend final : public vector::IEmbeddingBackend { public: explicit FixedEmbeddingBackend(std::vector embedding) : embedding_(std::move(embedding)) {} bool initialize() override { initialized_ = true; return true; } void shutdown() override { initialized_ = false; } bool isInitialized() const override { return initialized_; } Result> generateEmbedding(const std::string&) override { return embedding_; } Result>> generateEmbeddings(std::span texts) override { return std::vector>(texts.size(), embedding_); } size_t getEmbeddingDimension() const override { return embedding_.size(); } size_t getMaxSequenceLength() const override { return 512; } std::string getBackendName() const override { return "fixed-test"; } bool isAvailable() const override { return true; } vector::GenerationStats getStats() const override { return {}; } void resetStats() override {} private: std::vector embedding_; bool initialized_{false}; }; struct TopologySearchFixture { TopologySearchFixture() : disableVectors("YAMS_DISABLE_VECTORS", std::optional("0")), skipVecInit("YAMS_SQLITE_VEC_SKIP_INIT", std::optional("0")) { dbPath = tempDbPath("search_topology_"); ConnectionPoolConfig poolConfig; poolConfig.minConnections = 1; poolConfig.maxConnections = 2; pool = std::make_unique(dbPath.string(), poolConfig); REQUIRE(pool->initialize().has_value()); repo = std::make_shared(*pool); auto kgResult = makeSqliteKnowledgeGraphStore(*pool, KnowledgeGraphStoreConfig{}); REQUIRE(kgResult.has_value()); kgStore = std::shared_ptr(kgResult.value().release()); vector::VectorDatabaseConfig vectorConfig; vectorConfig.database_path = ":memory:"; vectorConfig.embedding_dim = 2; vectorConfig.create_if_missing = true; vectorConfig.use_in_memory = true; vectorDb = std::make_shared(vectorConfig); REQUIRE(vectorDb->initialize()); } ~TopologySearchFixture() { vectorDb.reset(); kgStore.reset(); repo.reset(); if (pool) { pool->shutdown(); } pool.reset(); std::error_code ec; std::filesystem::remove(dbPath, ec); } void addDocument(const std::string& hash, const std::string& content, const std::vector& embedding) { DocumentInfo info; info.filePath = "/tmp/" + hash + ".txt"; info.fileName = hash + ".txt"; info.fileExtension = ".txt"; info.fileSize = static_cast(content.size()); info.sha256Hash = hash; info.mimeType = "text/plain"; info.createdTime = std::chrono::floor(std::chrono::system_clock::now()); info.modifiedTime = info.createdTime; info.indexedTime = info.createdTime; auto docId = repo->insertDocument(info); REQUIRE(docId.has_value()); REQUIRE(repo->indexDocumentContent(docId.value(), hash, content, "text/plain").has_value()); DocumentContent docContent; docContent.documentId = docId.value(); docContent.contentText = content; docContent.contentLength = static_cast(content.size()); docContent.extractionMethod = "test"; docContent.language = "en"; REQUIRE(repo->insertContent(docContent).has_value()); vector::VectorRecord record; record.chunk_id = "doc-" + hash; record.document_hash = hash; record.embedding = embedding; record.level = vector::EmbeddingLevel::DOCUMENT; REQUIRE(vectorDb->insertVector(record)); } yams::test::ScopedEnvVar disableVectors; yams::test::ScopedEnvVar skipVecInit; std::filesystem::path dbPath; std::unique_ptr pool; std::shared_ptr repo; std::shared_ptr kgStore; std::shared_ptr vectorDb; }; } // namespace TEST_CASE("SearchEngine topology routing narrows weak-query vector candidates", "[search][topology][catch2]") { TopologySearchFixture fix; fix.addDocument("x1", "alpha one", {1.0F, 0.0F}); fix.addDocument("x2", "alpha two", {0.9F, 0.1F}); fix.addDocument("y1", "omega one", {0.0F, 1.0F}); fix.addDocument("y2", "omega two", {0.1F, 0.9F}); yams::topology::ConnectedComponentTopologyEngine topologyEngine; yams::topology::TopologyBuildConfig topologyConfig; topologyConfig.reciprocalOnly = true; topologyConfig.minEdgeScore = 0.5; std::vector topologyDocs{ {.documentHash = "x1", .filePath = "/tmp/x1.txt", .embedding = {1.0F, 0.0F}, .neighbors = {{.documentHash = "x2", .score = 0.9F, .reciprocal = true}}}, {.documentHash = "x2", .filePath = "/tmp/x2.txt", .embedding = {0.9F, 0.1F}, .neighbors = {{.documentHash = "x1", .score = 0.9F, .reciprocal = true}}}, {.documentHash = "y1", .filePath = "/tmp/y1.txt", .embedding = {0.0F, 1.0F}, .neighbors = {{.documentHash = "y2", .score = 0.9F, .reciprocal = true}}}, {.documentHash = "y2", .filePath = "/tmp/y2.txt", .embedding = {0.1F, 0.9F}, .neighbors = {{.documentHash = "y1", .score = 0.9F, .reciprocal = true}}}, }; auto topologyBatch = topologyEngine.buildArtifacts(topologyDocs, topologyConfig); REQUIRE(topologyBatch.has_value()); yams::topology::MetadataKgTopologyArtifactStore topologyStore(fix.repo, fix.kgStore); REQUIRE(topologyStore.storeBatch(topologyBatch.value()).has_value()); vector::EmbeddingConfig embeddingConfig; embeddingConfig.embedding_dim = 2; auto generator = std::make_shared( std::make_unique(std::vector{0.0F, 1.0F}), embeddingConfig); REQUIRE(generator->initialize()); SearchEngineConfig config; config.includeDebugInfo = true; config.enableParallelExecution = true; config.enableTieredExecution = true; config.textWeight = 1.0F; config.simeonTextWeight = 0.0F; config.pathTreeWeight = 0.0F; config.kgWeight = 0.0F; config.vectorWeight = 1.0F; config.entityVectorWeight = 0.0F; config.tagWeight = 0.0F; config.metadataWeight = 0.0F; config.vectorOnlyThreshold = 0.0F; config.vectorOnlyPenalty = 1.0F; config.vectorMaxResults = 4; config.enableTopologyWeakQueryRouting = true; config.topologyMaxClusters = 1; config.topologyMaxDocs = 2; SearchExecutionContext context = defaultSearchExecutionContext(); context.freshness.lexicalReady = true; context.freshness.vectorReady = true; context.freshness.kgReady = true; context.freshness.topologyReady = true; SearchExecutionContextGuard contextGuard(context); SearchEngine engine(fix.repo, fix.vectorDb, generator, fix.kgStore, config); SearchParams params; params.limit = 4; auto response = engine.searchWithResponse("unmatched query", params); REQUIRE(response.has_value()); const auto& debug = response.value().debugStats; REQUIRE(debug.contains("topology_weak_query_applied")); CHECK(debug.at("topology_weak_query_applied") == "1"); CHECK(debug.at("topology_weak_query_narrow_applied") == "1"); CHECK(debug.at("topology_weak_query_routed_clusters") == "1"); CHECK(debug.at("topology_weak_query_added_candidates") == "2"); CHECK(debug.at("topology_weak_query_total_candidates") == "2"); } TEST_CASE("SearchEngine stage trace reports reranker flags as booleans", "[search][trace][rerank][catch2]") { yams::test::ScopedEnvVar stageTrace("YAMS_SEARCH_STAGE_TRACE", std::optional("1")); yams::test::ScopedEnvVar traceTopN("YAMS_SEARCH_STAGE_TRACE_TOP_N", std::optional("4")); TopologySearchFixture fix; fix.addDocument("x1", "alpha one", {1.0F, 0.0F}); fix.addDocument("x2", "alpha two", {0.9F, 0.1F}); SearchEngineConfig config; config.includeDebugInfo = true; config.enableParallelExecution = false; config.enableTieredExecution = false; config.textWeight = 1.0F; config.simeonTextWeight = 0.0F; config.pathTreeWeight = 0.0F; config.kgWeight = 0.0F; config.vectorWeight = 0.0F; config.entityVectorWeight = 0.0F; config.tagWeight = 0.0F; config.metadataWeight = 0.0F; config.enableGraphRerank = false; config.enableReranking = true; config.rerankTopK = 5; SearchExecutionContext context = defaultSearchExecutionContext(); context.freshness.lexicalReady = true; SearchExecutionContextGuard contextGuard(context); SearchEngine engine(fix.repo, fix.vectorDb, nullptr, fix.kgStore, config); SearchParams params; params.limit = 2; auto response = engine.searchWithResponse("alpha", params); REQUIRE(response.has_value()); const auto& debug = response.value().debugStats; REQUIRE(debug.contains("trace_enabled")); CHECK(debug.at("trace_enabled") == "1"); REQUIRE(debug.contains("trace_cross_rerank_applied")); CHECK(debug.at("trace_cross_rerank_applied") == "0"); REQUIRE(debug.contains("trace_turboquant_rerank_applied")); CHECK(debug.at("trace_turboquant_rerank_applied") == "0"); REQUIRE(debug.contains("trace_rerank_guard_score_gap")); CHECK(debug.at("trace_rerank_guard_score_gap") == "0.000000"); } TEST_CASE("SearchEngine cross reranker promotes lower fused candidate", "[search][rerank][catch2]") { TopologySearchFixture fix; fix.addDocument("x1", "alpha one", {1.0F, 0.0F}); fix.addDocument("x2", "alpha two", {0.9F, 0.1F}); SearchEngineConfig config; config.includeDebugInfo = true; config.enableParallelExecution = false; config.enableTieredExecution = false; config.fusionStrategy = SearchEngineConfig::FusionStrategy::WEIGHTED_SUM; config.textWeight = 1.0F; config.simeonTextWeight = 0.0F; config.pathTreeWeight = 0.0F; config.kgWeight = 0.0F; config.vectorWeight = 0.0F; config.entityVectorWeight = 0.0F; config.tagWeight = 0.0F; config.metadataWeight = 0.0F; config.enableGraphRerank = false; config.enableReranking = true; config.rerankTopK = 2; config.rerankReplaceScores = true; SearchExecutionContext context = defaultSearchExecutionContext(); context.freshness.lexicalReady = true; SearchExecutionContextGuard contextGuard(context); SearchEngine engine(fix.repo, fix.vectorDb, nullptr, fix.kgStore, config); engine.setCrossReranker( [](const std::string&, const std::vector& documents) -> Result> { std::vector scores; scores.reserve(documents.size()); for (const auto& doc : documents) { scores.push_back(doc.find("x2") != std::string::npos ? 1.0F : 0.1F); } return scores; }); SearchParams params; params.limit = 2; auto response = engine.searchWithResponse("alpha", params); REQUIRE(response.has_value()); REQUIRE(response.value().results.size() == 2); CHECK(response.value().results.front().document.sha256Hash == "x2"); const auto& debug = response.value().debugStats; CHECK(debug.at("cross_rerank_available") == "1"); CHECK(debug.at("cross_rerank_skip_reason").empty()); CHECK(debug.at("cross_rerank_replace_scores") == "1"); }