Pin the current behaviour of the vector candidate stage in
search_engine.cpp before it is extracted into search_vector_stage.{h,cpp}.
A new catch2_search_vector_stage binary runs the matrix policy
{Shadow, Augment, Narrow commit, Narrow fallback} x backend
{SimeonPqAdc, ExactScan, Vec0L2} with stage trace off and on, plus one
executor-backed fanout case, and compares each cell against explicit
goldens: fused hash/rank/score and vector contribution, raw vector
ComponentResults (trace_component_hits_json), the stage's debug keys
without timings, certificate.work / admission.work via the
topology_route_work_* and admission keys, and the merged vector_search_*
diagnostics. Quirks are pinned as-is: relaxed_vector_primary_hit_count is
last-writer, Vec0 keeps global results unchanged under Shadow/Augment,
Narrow commits only on Simeon PQ because ExactScan and Vec0 never observe
an ANN candidate budget (work_observation_unavailable), and Vec0 leaks a
partial ann_candidate_budget_actual into the unobserved work record. A
hidden [.dump] case regenerates the goldens.
Move the shared topology fixtures (TopologySearchFixture,
buildTwoClusterTopologyBatch, topologyRoutingTestConfig,
runTopologySearch, configureCertifiedTopologyRoute, TopologyVectorEnvPin)
into tests/unit/search/topology_search_test_support.h so both tests use
the same corpus. runTopologySearch takes an optional executor and
configureCertifiedTopologyRoute an options struct; existing call sites are
unchanged. The portability allowlist entry for the raw YAMS_TEST_TMPDIR
read moves with the helper. No product behaviour changes.
Signed-off-by: trvon <me@trevon.dev>
2321 lines
110 KiB
C++
2321 lines
110 KiB
C++
#include <nlohmann/json.hpp>
|
|
#include <catch2/catch_approx.hpp>
|
|
#include <catch2/catch_test_macros.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <memory>
|
|
#include <optional>
|
|
#include <span>
|
|
#include <string>
|
|
#include <tuple>
|
|
#include <unordered_map>
|
|
#include <unordered_set>
|
|
#include <vector>
|
|
|
|
#include <yams/metadata/connection_pool.h>
|
|
#include <yams/metadata/knowledge_graph_store.h>
|
|
#include <yams/metadata/metadata_repository.h>
|
|
#include <yams/search/search_engine.h>
|
|
#include <yams/search/search_execution_context.h>
|
|
#include <yams/search/search_topology_stage.h>
|
|
#include <yams/search/topology_routing_session.h>
|
|
#include <yams/topology/topology_baseline.h>
|
|
#include <yams/topology/topology_metadata_store.h>
|
|
#include <yams/vector/embedding_generator.h>
|
|
#include <yams/vector/vector_database.h>
|
|
|
|
#include "src/search/search_vector_pipeline_internal.h"
|
|
#include "tests/common/test_helpers_catch2.h"
|
|
#include "tests/unit/search/topology_search_test_support.h"
|
|
|
|
using namespace yams;
|
|
using namespace yams::metadata;
|
|
using namespace yams::search;
|
|
using namespace yams::search::topology_test_support;
|
|
|
|
// Catch2 assertion macros expand through do/while and large generated test bodies.
|
|
// NOLINTBEGIN(cppcoreguidelines-avoid-do-while, readability-function-cognitive-complexity)
|
|
|
|
namespace {
|
|
|
|
class HashLookupCountingRepository : public MetadataRepository {
|
|
public:
|
|
explicit HashLookupCountingRepository(ConnectionPool& pool) : MetadataRepository(pool) {}
|
|
|
|
Result<std::optional<DocumentInfo>> getDocumentByHash(const std::string& hash) override {
|
|
++singleLookups;
|
|
return MetadataRepository::getDocumentByHash(hash);
|
|
}
|
|
|
|
Result<std::unordered_map<std::string, DocumentInfo>>
|
|
batchGetDocumentsByHash(const std::vector<std::string>& hashes) override {
|
|
const auto call = ++batchLookups;
|
|
if (failBatchLookups || (failBatchAt > 0 && call == failBatchAt)) {
|
|
return Error{ErrorCode::InternalError, "injected metadata lookup failure"};
|
|
}
|
|
return MetadataRepository::batchGetDocumentsByHash(hashes);
|
|
}
|
|
|
|
std::atomic<int> singleLookups{0};
|
|
std::atomic<int> batchLookups{0};
|
|
bool failBatchLookups{false};
|
|
int failBatchAt{0};
|
|
};
|
|
|
|
yams::topology::TopologyArtifactBatch buildMultiscaleOverlapTopologyBatch() {
|
|
auto batch = buildTwoClusterTopologyBatch();
|
|
auto fineIt = std::ranges::find_if(batch.clusters, [](const auto& cluster) {
|
|
return std::ranges::find(cluster.memberDocumentHashes, "y1") !=
|
|
cluster.memberDocumentHashes.end();
|
|
});
|
|
REQUIRE(fineIt != batch.clusters.end());
|
|
|
|
yams::topology::ClusterArtifact coarse = *fineIt;
|
|
coarse.clusterId += ":coarse";
|
|
coarse.level = 1;
|
|
coarse.persistenceScore = 0.70;
|
|
coarse.cohesionScore = 0.80;
|
|
coarse.bridgeMass = 0.30;
|
|
coarse.densityScore = 0.60;
|
|
coarse.parentClusterId.reset();
|
|
coarse.overlapClusterIds = {fineIt->clusterId};
|
|
if (coarse.medoid.has_value()) {
|
|
coarse.medoid->clusterId = coarse.clusterId;
|
|
}
|
|
|
|
fineIt->parentClusterId = coarse.clusterId;
|
|
fineIt->overlapClusterIds = {coarse.clusterId};
|
|
for (auto& membership : batch.memberships) {
|
|
if (membership.documentHash != "y1" && membership.documentHash != "y2") {
|
|
continue;
|
|
}
|
|
membership.parentClusterId = coarse.clusterId;
|
|
membership.overlapClusterIds = {coarse.clusterId};
|
|
membership.bridgeScore = membership.documentHash == "y1" ? 0.60 : 0.20;
|
|
}
|
|
batch.clusters.push_back(std::move(coarse));
|
|
return batch;
|
|
}
|
|
|
|
void seedTopologyWithStaleClusterMember(TopologySearchFixture& fix) {
|
|
auto topologyBatch = buildTwoClusterTopologyBatch();
|
|
REQUIRE(!topologyBatch.clusters.empty());
|
|
topologyBatch.clusters.front().memberDocumentHashes.push_back("missing-hash");
|
|
topologyBatch.clusters.front().memberCount =
|
|
topologyBatch.clusters.front().memberDocumentHashes.size();
|
|
yams::topology::MetadataKgTopologyArtifactStore topologyStore(fix.repo, fix.kgStore);
|
|
REQUIRE(topologyStore.storeBatch(topologyBatch).has_value());
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("SearchEngine topology refuses uncertified weak-query narrowing",
|
|
"[search][topology][admission][catch2]") {
|
|
TopologySearchFixture fix{vector::VectorSearchEngine::Vec0L2};
|
|
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<yams::topology::TopologyDocumentInput> 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<vector::EmbeddingGenerator>(
|
|
std::make_unique<FixedEmbeddingBackend>(std::vector<float>{0.0F, 1.0F}), embeddingConfig);
|
|
REQUIRE(generator->initialize());
|
|
|
|
SearchEngineConfig config;
|
|
config.includeDebugInfo = true;
|
|
config.enableParallelExecution = 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.topologyVectorPolicy = SearchEngineConfig::TopologyVectorPolicy::Narrow;
|
|
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;
|
|
CHECK((debug.at("search_pipeline_name") == "topology_evidence"));
|
|
REQUIRE(debug.contains("topology_weak_query_applied"));
|
|
CHECK((debug.at("topology_weak_query_load_attempted") == "1"));
|
|
CHECK((debug.at("topology_weak_query_load_succeeded") == "1"));
|
|
CHECK(debug.at("topology_weak_query_skip_reason").empty());
|
|
CHECK((debug.at("topology_weak_query_applied") == "1"));
|
|
CHECK((debug.at("topology_route_narrow_proposed") == "1"));
|
|
CHECK((debug.at("topology_route_admission_eligible") == "0"));
|
|
CHECK((debug.at("topology_route_admission_denial_reason") ==
|
|
"protected_fibers_representation_unavailable"));
|
|
CHECK((debug.at("topology_weak_query_narrow_applied") == "0"));
|
|
CHECK((debug.at("topology_weak_query_allowed_candidates") == "2"));
|
|
CHECK((debug.at("topology_vector_filter_applied") == "0"));
|
|
CHECK((debug.at("topology_vector_filter_fallback") == "0"));
|
|
CHECK((debug.at("topology_vector_allowed_set_ann_applied") == "0"));
|
|
CHECK((debug.at("topology_vector_allowed_set_ann_fallback") == "0"));
|
|
CHECK_FALSE(debug.contains("topology_vector_partition_ann_applied"));
|
|
CHECK((debug.at("topology_vector_filter_matched") == "0"));
|
|
CHECK((debug.at("topology_vector_filter_removed") == "0"));
|
|
CHECK_FALSE(debug.contains("topology_vector_scores_reused"));
|
|
CHECK_FALSE(debug.contains("topology_vector_scores_reused_count"));
|
|
CHECK_FALSE(debug.contains("topology_member_rerank_rows_visited_actual"));
|
|
CHECK_FALSE(debug.contains("topology_member_rerank_distance_evaluations_actual"));
|
|
CHECK(debug.at("vector_search_rows_visited_status") == "unavailable");
|
|
CHECK(debug.at("vector_search_exact_distance_evaluations_status") == "unavailable");
|
|
CHECK(debug.at("vector_search_ann_candidate_budget_status") == "unavailable");
|
|
CHECK_FALSE(debug.contains("vector_search_rows_visited_actual"));
|
|
CHECK_FALSE(debug.contains("vector_search_exact_distance_evaluations_actual"));
|
|
CHECK_FALSE(debug.contains("vector_search_ann_candidate_budget_actual"));
|
|
CHECK((debug.at("topology_weak_query_routed_clusters") == "1"));
|
|
CHECK((debug.at("topology_weak_query_added_candidates") == "0"));
|
|
CHECK((debug.at("topology_weak_query_total_candidates") == "0"));
|
|
CHECK((debug.at("topology_snapshot_cache_hit") == "0"));
|
|
|
|
auto cachedResponse = engine.searchWithResponse("unmatched query", params);
|
|
REQUIRE(cachedResponse.has_value());
|
|
CHECK((cachedResponse.value().debugStats.at("topology_snapshot_cache_hit") == "1"));
|
|
}
|
|
|
|
TEST_CASE("SearchEngine keeps Simeon PQ global while route admission is unavailable",
|
|
"[search][topology][spq][admission][catch2]") {
|
|
TopologySearchFixture fix{vector::VectorSearchEngine::SimeonPqAdc};
|
|
seedTopologyDocuments(fix);
|
|
seedTwoClusterTopology(fix);
|
|
REQUIRE(fix.vectorDb->buildIndex());
|
|
|
|
auto generator = makeFixedGenerator({0.1F, 0.9F});
|
|
auto config = topologyRoutingTestConfig(true);
|
|
config.topologyRoutingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
config.topologyNarrowMinBoundaryMargin = 0.0F;
|
|
config.topologyMaxDocs = 1;
|
|
|
|
auto response = runTopologySearch(fix, generator, config, 4);
|
|
REQUIRE(response.has_value());
|
|
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK(debug.at("topology_weak_query_allowed_candidates") == "2");
|
|
CHECK(debug.at("topology_route_narrow_proposed") == "1");
|
|
CHECK(debug.at("topology_route_admission_eligible") == "0");
|
|
CHECK(debug.at("topology_route_admission_denial_reason") ==
|
|
"protected_fibers_representation_unavailable");
|
|
CHECK(debug.at("topology_vector_allowed_set_ann_applied") == "0");
|
|
CHECK(debug.at("topology_vector_allowed_set_ann_fallback") == "0");
|
|
CHECK(debug.at("topology_weak_query_narrow_applied") == "0");
|
|
CHECK(debug.at("vector_search_rows_visited_status") == "observed");
|
|
CHECK(debug.at("vector_search_exact_distance_evaluations_status") == "observed");
|
|
CHECK(debug.at("vector_search_ann_candidate_budget_status") == "observed");
|
|
CHECK(debug.at("vector_search_ann_candidate_budget_actual") == "4");
|
|
CHECK(debug.at("vector_search_rows_visited_actual") == "4");
|
|
CHECK(debug.at("vector_search_exact_distance_evaluations_actual") == "4");
|
|
REQUIRE_FALSE(response.value().results.empty());
|
|
}
|
|
|
|
TEST_CASE("SearchEngine commits hard narrowing when every theorem certificate passes",
|
|
"[search][topology][admission][work][catch2]") {
|
|
TopologySearchFixture fix{vector::VectorSearchEngine::SimeonPqAdc};
|
|
seedTopologyDocuments(fix);
|
|
auto batch = buildTwoClusterTopologyBatch();
|
|
yams::topology::MetadataKgTopologyArtifactStore topologyStore(fix.repo, fix.kgStore);
|
|
REQUIRE(topologyStore.storeBatch(batch).has_value());
|
|
|
|
auto generator = makeFixedGenerator({0.0F, 1.0F});
|
|
auto config = topologyRoutingTestConfig(true);
|
|
configureCertifiedTopologyRoute(config, batch);
|
|
|
|
auto response = runTopologySearch(fix, generator, config, 4, "omega");
|
|
REQUIRE(response.has_value());
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK(debug.at("topology_route_coordinate_space_identity") == "test-space-v1");
|
|
CHECK(debug.at("topology_route_coordinate_space_alignment_status") == "satisfied");
|
|
CHECK(debug.at("topology_route_protected_relation_coverage_status") == "satisfied");
|
|
CHECK(debug.at("topology_route_protected_fibers_represented_status") == "satisfied");
|
|
CHECK(debug.at("topology_route_certificate_saturates_protected_fibers_status") == "satisfied");
|
|
CHECK(debug.at("topology_route_risk_status") == "satisfied");
|
|
CHECK(debug.at("topology_route_work_status") == "satisfied");
|
|
CHECK(debug.at("topology_route_cover_materialization_status") == "satisfied");
|
|
CHECK(debug.at("topology_route_admission_eligible") == "1");
|
|
CHECK(debug.at("topology_weak_query_narrow_applied") == "1");
|
|
CHECK(debug.at("topology_vector_allowed_set_ann_applied") == "1");
|
|
CHECK(debug.at("topology_vector_global_fill_count") == "0");
|
|
REQUIRE(response.value().results.size() == 2U);
|
|
CHECK(std::ranges::all_of(response.value().results, [](const auto& result) {
|
|
return result.document.sha256Hash == "y1" || result.document.sha256Hash == "y2";
|
|
}));
|
|
}
|
|
|
|
TEST_CASE("SearchEngine discards a trial route whose observed vector work exceeds its budget",
|
|
"[search][topology][admission][work][fallback][catch2]") {
|
|
TopologySearchFixture fix{vector::VectorSearchEngine::SimeonPqAdc};
|
|
seedTopologyDocuments(fix);
|
|
auto batch = buildTwoClusterTopologyBatch();
|
|
yams::topology::MetadataKgTopologyArtifactStore topologyStore(fix.repo, fix.kgStore);
|
|
REQUIRE(topologyStore.storeBatch(batch).has_value());
|
|
|
|
auto generator = makeFixedGenerator({0.0F, 1.0F});
|
|
auto config = topologyRoutingTestConfig(true);
|
|
configureCertifiedTopologyRoute(config, batch, 1);
|
|
|
|
auto response = runTopologySearch(fix, generator, config, 4, "omega");
|
|
REQUIRE(response.has_value());
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK(debug.at("topology_route_work_status") == "violated");
|
|
CHECK(debug.at("topology_route_admission_eligible") == "0");
|
|
CHECK(debug.at("topology_route_admission_denial_reason") == "work_budget_violated");
|
|
CHECK(debug.at("topology_weak_query_narrow_applied") == "0");
|
|
CHECK(debug.at("topology_vector_filter_fallback") == "1");
|
|
CHECK(response.value().results.size() == 4U);
|
|
}
|
|
|
|
TEST_CASE("SearchEngine rejects a relation cover that cuts an observed protected pair",
|
|
"[search][topology][admission][pairs][fallback][catch2]") {
|
|
TopologySearchFixture fix{vector::VectorSearchEngine::SimeonPqAdc};
|
|
seedTopologyDocuments(fix);
|
|
auto batch = buildTwoClusterTopologyBatch();
|
|
auto selectedChart = std::ranges::find_if(batch.clusters, [](const auto& cluster) {
|
|
return std::ranges::find(cluster.memberDocumentHashes, "y1") !=
|
|
cluster.memberDocumentHashes.end();
|
|
});
|
|
REQUIRE(selectedChart != batch.clusters.end());
|
|
REQUIRE(selectedChart->protectedPairCount > 0);
|
|
selectedChart->preservedProtectedPairCount = 0;
|
|
yams::topology::MetadataKgTopologyArtifactStore topologyStore(fix.repo, fix.kgStore);
|
|
REQUIRE(topologyStore.storeBatch(batch).has_value());
|
|
|
|
auto generator = makeFixedGenerator({0.0F, 1.0F});
|
|
auto config = topologyRoutingTestConfig(true);
|
|
configureCertifiedTopologyRoute(config, batch);
|
|
|
|
auto response = runTopologySearch(fix, generator, config, 4, "omega");
|
|
REQUIRE(response.has_value());
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK(debug.at("topology_route_protected_relation_coverage_status") == "violated");
|
|
CHECK(debug.at("topology_route_admission_denial_reason") ==
|
|
"protected_relation_coverage_violated");
|
|
CHECK(debug.at("topology_route_work_status") == "unavailable");
|
|
CHECK(debug.at("topology_weak_query_narrow_applied") == "0");
|
|
CHECK(response.value().results.size() == 4U);
|
|
}
|
|
|
|
TEST_CASE("SearchEngine rejects route calibration from a different topology construction",
|
|
"[search][topology][admission][risk][fallback][catch2]") {
|
|
TopologySearchFixture fix{vector::VectorSearchEngine::SimeonPqAdc};
|
|
seedTopologyDocuments(fix);
|
|
auto batch = buildTwoClusterTopologyBatch();
|
|
yams::topology::MetadataKgTopologyArtifactStore topologyStore(fix.repo, fix.kgStore);
|
|
REQUIRE(topologyStore.storeBatch(batch).has_value());
|
|
|
|
auto generator = makeFixedGenerator({0.0F, 1.0F});
|
|
auto config = topologyRoutingTestConfig(true);
|
|
configureCertifiedTopologyRoute(config, batch);
|
|
config.topologyRouteRiskCalibration.constructionFingerprint = "different-construction";
|
|
|
|
auto response = runTopologySearch(fix, generator, config, 4, "omega");
|
|
REQUIRE(response.has_value());
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK(debug.at("topology_route_protected_relation_coverage_status") == "satisfied");
|
|
CHECK(debug.at("topology_route_risk_status") == "unavailable");
|
|
CHECK(debug.at("topology_route_admission_denial_reason") ==
|
|
"protected_fibers_representation_unavailable");
|
|
CHECK(debug.at("topology_weak_query_narrow_applied") == "0");
|
|
CHECK(response.value().results.size() == 4U);
|
|
}
|
|
|
|
TEST_CASE("SearchEngine does not infer protected fiber representation from bounded miss risk",
|
|
"[search][topology][admission][fiber][fallback][catch2]") {
|
|
TopologySearchFixture fix{vector::VectorSearchEngine::SimeonPqAdc};
|
|
seedTopologyDocuments(fix);
|
|
auto batch = buildTwoClusterTopologyBatch();
|
|
yams::topology::MetadataKgTopologyArtifactStore topologyStore(fix.repo, fix.kgStore);
|
|
REQUIRE(topologyStore.storeBatch(batch).has_value());
|
|
|
|
auto generator = makeFixedGenerator({0.0F, 1.0F});
|
|
auto config = topologyRoutingTestConfig(true);
|
|
configureCertifiedTopologyRoute(config, batch);
|
|
config.topologyRouteRiskCalibration.missedProtectedCandidates = 1;
|
|
|
|
auto response = runTopologySearch(fix, generator, config, 4, "omega");
|
|
REQUIRE(response.has_value());
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK(debug.at("topology_route_risk_status") == "satisfied");
|
|
CHECK(debug.at("topology_route_protected_fibers_represented_status") == "violated");
|
|
CHECK(debug.at("topology_route_admission_denial_reason") == "protected_fibers_unrepresented");
|
|
CHECK(debug.at("topology_weak_query_narrow_applied") == "0");
|
|
}
|
|
|
|
TEST_CASE("SearchEngine keeps multi-route proposals global until admitted",
|
|
"[search][topology][spq][balance][admission][catch2]") {
|
|
TopologySearchFixture fix{vector::VectorSearchEngine::SimeonPqAdc};
|
|
seedTopologyDocuments(fix);
|
|
seedTwoClusterTopology(fix);
|
|
REQUIRE(fix.vectorDb->buildIndex());
|
|
|
|
auto generator = makeFixedGenerator({0.0F, 1.0F});
|
|
auto config = topologyRoutingTestConfig(true);
|
|
config.topologyRoutingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
config.topologyNarrowMinBoundaryMargin = 0.0F;
|
|
config.topologyMinClusters = 2;
|
|
config.topologyMaxClusters = 2;
|
|
config.topologyMaxDocs = 2;
|
|
config.similarityThreshold = -1.0F;
|
|
|
|
auto response = runTopologySearch(fix, generator, config, 2);
|
|
REQUIRE(response.has_value());
|
|
REQUIRE(response.value().results.size() == 2U);
|
|
|
|
std::unordered_set<std::string> resultHashes;
|
|
for (const auto& result : response.value().results) {
|
|
resultHashes.insert(result.document.sha256Hash);
|
|
}
|
|
CHECK_FALSE((resultHashes.contains("x1") || resultHashes.contains("x2")));
|
|
CHECK((resultHashes.contains("y1") || resultHashes.contains("y2")));
|
|
CHECK(response.value().debugStats.at("topology_route_narrow_proposed") == "1");
|
|
CHECK(response.value().debugStats.at("topology_route_admission_eligible") == "0");
|
|
}
|
|
|
|
TEST_CASE("SearchEngine does not bound Simeon to an uncertified route",
|
|
"[search][topology][spq][fill][admission][catch2]") {
|
|
TopologySearchFixture fix{vector::VectorSearchEngine::SimeonPqAdc};
|
|
seedTopologyDocuments(fix);
|
|
seedTwoClusterTopology(fix);
|
|
REQUIRE(fix.vectorDb->buildIndex());
|
|
|
|
auto generator = makeFixedGenerator({0.0F, 1.0F});
|
|
auto config = topologyRoutingTestConfig(true);
|
|
config.topologyRoutingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
config.topologyNarrowMinBoundaryMargin = 0.0F;
|
|
config.topologyMinClusters = 1;
|
|
config.topologyMaxClusters = 1;
|
|
config.topologyMaxDocs = 4;
|
|
config.similarityThreshold = -1.0F;
|
|
|
|
auto response = runTopologySearch(fix, generator, config, 4);
|
|
REQUIRE(response.has_value());
|
|
CHECK(response.value().results.size() == 4U);
|
|
CHECK(response.value().debugStats.at("topology_vector_global_fill_count") == "0");
|
|
CHECK(response.value().debugStats.at("vector_search_ann_candidate_budget_actual") == "4");
|
|
CHECK(response.value().debugStats.at("topology_route_admission_eligible") == "0");
|
|
}
|
|
|
|
TEST_CASE("SearchEngine uses global Vec0 directly for an uncertified route",
|
|
"[search][topology][vec0][fill][admission][catch2]") {
|
|
TopologySearchFixture fix{vector::VectorSearchEngine::Vec0L2};
|
|
seedTopologyDocuments(fix);
|
|
seedTwoClusterTopology(fix);
|
|
|
|
auto generator = makeFixedGenerator({0.0F, 1.0F});
|
|
auto config = topologyRoutingTestConfig(true);
|
|
config.topologyRoutingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
config.topologyNarrowMinBoundaryMargin = 0.0F;
|
|
config.topologyMinClusters = 1;
|
|
config.topologyMaxClusters = 1;
|
|
config.topologyMaxDocs = 4;
|
|
config.similarityThreshold = -1.0F;
|
|
|
|
auto response = runTopologySearch(fix, generator, config, 4);
|
|
REQUIRE(response.has_value());
|
|
CHECK(response.value().results.size() == 4U);
|
|
CHECK(response.value().debugStats.at("topology_vector_global_fill_count") == "0");
|
|
CHECK(response.value().debugStats.at("topology_route_admission_eligible") == "0");
|
|
}
|
|
|
|
TEST_CASE("SearchEngine topology abstains from narrowing at an ambiguous route boundary",
|
|
"[search][topology][spq][confidence][catch2]") {
|
|
TopologySearchFixture fix{vector::VectorSearchEngine::SimeonPqAdc};
|
|
seedTopologyDocuments(fix);
|
|
seedTwoClusterTopology(fix);
|
|
REQUIRE(fix.vectorDb->buildIndex());
|
|
|
|
auto generator = makeFixedGenerator({1.0F, 1.0F});
|
|
auto config = topologyRoutingTestConfig(true);
|
|
config.topologyRoutingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
config.topologySparseDenseAlpha = 0.0F;
|
|
config.topologyMinClusters = 1;
|
|
config.topologyMaxClusters = 1;
|
|
config.topologyNarrowMinBoundaryMargin = 0.20F;
|
|
config.topologyMaxDocs = 2;
|
|
config.similarityThreshold = -1.0F;
|
|
|
|
auto response = runTopologySearch(fix, generator, config, 4);
|
|
REQUIRE(response.has_value());
|
|
CHECK(response.value().results.size() == 4U);
|
|
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK(debug.at("topology_route_confidence_abstained") == "1");
|
|
CHECK(debug.at("topology_weak_query_skip_reason") == "route_low_confidence");
|
|
CHECK(debug.at("topology_weak_query_narrow_applied") == "0");
|
|
CHECK(debug.at("topology_vector_allowed_set_ann_applied") == "0");
|
|
CHECK(debug.at("vector_search_ann_candidate_budget_actual") == "4");
|
|
}
|
|
|
|
TEST_CASE("SearchEngine topology shadow leaves global ranking and scores unchanged",
|
|
"[search][topology][shadow][catch2]") {
|
|
yams::test::ScopedEnvVar stageTrace("YAMS_SEARCH_STAGE_TRACE", std::optional<std::string>("1"));
|
|
|
|
TopologySearchFixture fix{vector::VectorSearchEngine::SimeonPqAdc};
|
|
seedTopologyDocuments(fix);
|
|
seedTwoClusterTopology(fix);
|
|
REQUIRE(fix.vectorDb->buildIndex());
|
|
auto generator = makeFixedGenerator({0.0F, 1.0F});
|
|
|
|
SearchExecutionContext context = defaultSearchExecutionContext();
|
|
context.freshness.lexicalReady = true;
|
|
context.freshness.vectorReady = true;
|
|
context.freshness.kgReady = true;
|
|
context.freshness.topologyReady = true;
|
|
SearchExecutionContextGuard contextGuard(context);
|
|
|
|
auto globalConfig = topologyRoutingTestConfig(false);
|
|
SearchEngine globalEngine(fix.repo, fix.vectorDb, generator, fix.kgStore, globalConfig);
|
|
|
|
auto shadowConfig = topologyRoutingTestConfig(true);
|
|
shadowConfig.topologyRoutingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
shadowConfig.topologyVectorPolicy = SearchEngineConfig::TopologyVectorPolicy::Shadow;
|
|
shadowConfig.topologySparseDenseAlpha = 0.0F;
|
|
shadowConfig.topologyNarrowMinBoundaryMargin = 0.0F;
|
|
shadowConfig.topologyRouteWorkBudget.maxRowsVisited = 4;
|
|
shadowConfig.topologyRouteWorkBudget.maxExactDistanceEvaluations = 4;
|
|
shadowConfig.topologyRouteWorkBudget.maxAnnCandidates = 4;
|
|
SearchEngine shadowEngine(fix.repo, fix.vectorDb, generator, fix.kgStore, shadowConfig);
|
|
|
|
SearchParams params;
|
|
params.limit = 4;
|
|
auto global = globalEngine.searchWithResponse("unmatched query", params);
|
|
auto shadow = shadowEngine.searchWithResponse("unmatched query", params);
|
|
REQUIRE(global.has_value());
|
|
REQUIRE(shadow.has_value());
|
|
REQUIRE(shadow.value().results.size() == global.value().results.size());
|
|
bool scoreChanged = false;
|
|
for (std::size_t index = 0; index < global.value().results.size(); ++index) {
|
|
CHECK(shadow.value().results[index].document.sha256Hash ==
|
|
global.value().results[index].document.sha256Hash);
|
|
scoreChanged = scoreChanged ||
|
|
shadow.value().results[index].score != global.value().results[index].score;
|
|
}
|
|
CHECK_FALSE(scoreChanged);
|
|
|
|
const auto& debug = shadow.value().debugStats;
|
|
CHECK(debug.at("topology_vector_policy") == "shadow");
|
|
CHECK(debug.at("topology_shadow_evaluated") == "1");
|
|
CHECK(debug.at("topology_shadow_proposed_action") == "narrow");
|
|
CHECK(debug.at("topology_shadow_retained_candidates") == "2");
|
|
CHECK(debug.at("topology_shadow_removed_candidates") == "2");
|
|
CHECK(debug.at("topology_shadow_retained_candidate_doc_ids") == "y1\ty2");
|
|
CHECK(debug.at("topology_shadow_removed_candidate_doc_ids") == "x1\tx2");
|
|
CHECK(debug.at("topology_route_work_status") == "satisfied");
|
|
CHECK(debug.at("topology_route_work_observed") == "1");
|
|
CHECK(debug.at("topology_route_work_rows_visited_actual") == "2");
|
|
CHECK(debug.at("topology_route_work_exact_distance_evaluations_actual") == "2");
|
|
CHECK(debug.at("topology_route_work_ann_candidate_budget_actual") == "2");
|
|
CHECK(debug.at("topology_weak_query_applied") == "0");
|
|
CHECK(debug.at("topology_weak_query_narrow_applied") == "0");
|
|
CHECK(debug.at("topology_weak_query_added_candidates") == "0");
|
|
CHECK(debug.at("topology_vector_filter_applied") == "0");
|
|
CHECK(debug.at("topology_vector_allowed_set_ann_applied") == "0");
|
|
}
|
|
|
|
TEST_CASE("SearchEngine topology augmentation materializes query-scored rescue candidates",
|
|
"[search][topology][candidate-rescue][catch2]") {
|
|
yams::test::ScopedEnvVar stageTrace("YAMS_SEARCH_STAGE_TRACE", std::optional<std::string>("1"));
|
|
|
|
for (const auto searchEngine :
|
|
{vector::VectorSearchEngine::SimeonPqAdc, vector::VectorSearchEngine::ExactScan}) {
|
|
CAPTURE(searchEngine);
|
|
TopologySearchFixture fix{searchEngine};
|
|
seedTopologyDocuments(fix);
|
|
seedTwoClusterTopology(fix);
|
|
REQUIRE(fix.vectorDb->buildIndex());
|
|
auto generator = makeFixedGenerator({0.0F, 1.0F});
|
|
|
|
auto config = topologyRoutingTestConfig(true);
|
|
config.topologyRoutingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
config.topologyVectorPolicy = SearchEngineConfig::TopologyVectorPolicy::Augment;
|
|
config.topologySparseDenseAlpha = 1.0F;
|
|
config.topologyNarrowMinBoundaryMargin = 0.0F;
|
|
config.textMaxResults = 1;
|
|
config.vectorMaxResults = 1;
|
|
config.enableWeakQueryFanoutBoost = false;
|
|
config.topologyMaxDocs = 2;
|
|
config.topologyExpansionOutputLimit = 2;
|
|
config.similarityThreshold = -1.0F;
|
|
|
|
auto response = runTopologySearch(fix, generator, config, 4, "alpha one");
|
|
REQUIRE(response.has_value());
|
|
|
|
std::vector<std::string> resultHashes;
|
|
for (const auto& result : response.value().results) {
|
|
resultHashes.push_back(result.document.sha256Hash);
|
|
}
|
|
CHECK(std::ranges::find(resultHashes, "x2") != resultHashes.end());
|
|
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK(debug.at("topology_vector_policy") == "augment");
|
|
CHECK(debug.at("topology_expansion_output_budget") == "2");
|
|
CHECK(debug.at("vector_search_result_budget") == "1");
|
|
CHECK(debug.at("topology_candidate_rescue_attempted") == "1");
|
|
CHECK(debug.at("topology_candidate_rescue_applied") == "1");
|
|
CHECK(debug.at("topology_candidate_rescue_added_candidates") == "2");
|
|
CHECK(debug.at("topology_candidate_rescue_novel_candidates") == "1");
|
|
CHECK(debug.at("topology_candidate_rescue_evidence_rescues") == "1");
|
|
CHECK(debug.at("topology_candidate_rescue_added_candidate_hashes") == "x2\tx1");
|
|
CHECK(debug.at("topology_candidate_rescue_added_candidate_doc_ids") == "x2\tx1");
|
|
CHECK(debug.at("topology_candidate_rescue_novel_candidate_doc_ids") == "x2");
|
|
CHECK(debug.at("topology_candidate_rescue_evidence_rescue_doc_ids") == "x1");
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Topology selector resolves candidate hashes to metadata IDs, not trace IDs",
|
|
"[search][topology][candidate-rescue][simeon][catch2]") {
|
|
TopologySearchFixture fix{vector::VectorSearchEngine::SimeonPqAdc};
|
|
fix.addDocument("31715818", "alpha one", {1.0F, 0.0F});
|
|
fix.addDocument("30655442", "beta two", {0.0F, 1.0F});
|
|
|
|
const std::vector<std::string> hashes = {"30655442", "missing", "31715818"};
|
|
auto resolved = yams::search::detail::resolveMetadataDocumentIdsByHash(fix.repo, hashes);
|
|
REQUIRE(resolved.has_value());
|
|
REQUIRE(resolved.value().size() == 2U);
|
|
|
|
auto first = fix.repo->getDocumentByHash("30655442");
|
|
auto second = fix.repo->getDocumentByHash("31715818");
|
|
REQUIRE(first.has_value());
|
|
REQUIRE(first.value().has_value());
|
|
REQUIRE(second.has_value());
|
|
REQUIRE(second.value().has_value());
|
|
|
|
CHECK(resolved.value().at(0).first == "30655442");
|
|
CHECK(resolved.value().at(0).second == first.value()->id);
|
|
CHECK(resolved.value().at(1).first == "31715818");
|
|
CHECK(resolved.value().at(1).second == second.value()->id);
|
|
}
|
|
|
|
TEST_CASE("SearchEngine shadow policy measures candidate rescue without changing results",
|
|
"[search][topology][candidate-rescue][shadow][catch2]") {
|
|
yams::test::ScopedEnvVar stageTrace("YAMS_SEARCH_STAGE_TRACE", std::optional<std::string>("1"));
|
|
|
|
TopologySearchFixture fix{vector::VectorSearchEngine::SimeonPqAdc};
|
|
seedTopologyDocuments(fix);
|
|
seedTwoClusterTopology(fix);
|
|
REQUIRE(fix.vectorDb->buildIndex());
|
|
auto generator = makeFixedGenerator({0.0F, 1.0F});
|
|
|
|
auto config = topologyRoutingTestConfig(true);
|
|
config.topologyRoutingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
config.topologyVectorPolicy = SearchEngineConfig::TopologyVectorPolicy::Shadow;
|
|
config.topologySparseDenseAlpha = 1.0F;
|
|
config.topologyNarrowMinBoundaryMargin = 0.0F;
|
|
config.textMaxResults = 1;
|
|
config.vectorMaxResults = 1;
|
|
config.topologyMaxDocs = 2;
|
|
config.topologyExpansionOutputLimit = 2;
|
|
config.similarityThreshold = -1.0F;
|
|
|
|
auto response = runTopologySearch(fix, generator, config, 4, "alpha one");
|
|
REQUIRE(response.has_value());
|
|
|
|
std::vector<std::string> resultHashes;
|
|
for (const auto& result : response.value().results) {
|
|
resultHashes.push_back(result.document.sha256Hash);
|
|
}
|
|
CHECK(std::ranges::find(resultHashes, "x2") == resultHashes.end());
|
|
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK(debug.at("topology_vector_policy") == "shadow");
|
|
CHECK(debug.at("topology_candidate_rescue_attempted") == "1");
|
|
CHECK(debug.at("topology_candidate_rescue_applied") == "0");
|
|
CHECK(debug.at("topology_candidate_rescue_added_candidates") == "2");
|
|
CHECK(debug.at("topology_candidate_rescue_novel_candidates") == "1");
|
|
CHECK(debug.at("topology_candidate_rescue_evidence_rescues") == "1");
|
|
CHECK(debug.at("topology_candidate_rescue_added_candidate_hashes") == "x2\tx1");
|
|
CHECK(debug.at("topology_candidate_rescue_added_candidate_doc_ids") == "x2\tx1");
|
|
CHECK(debug.at("topology_candidate_rescue_novel_candidate_doc_ids") == "x2");
|
|
CHECK(debug.at("topology_candidate_rescue_evidence_rescue_doc_ids") == "x1");
|
|
CHECK(debug.at("topology_candidate_rescue_exact_scored_candidate_doc_ids") == "x1\tx2");
|
|
const auto trace =
|
|
nlohmann::json::parse(debug.at("topology_candidate_rescue_exact_scored_candidate_rows"));
|
|
REQUIRE(trace.contains("vector"));
|
|
const auto& hits = trace.at("vector").at("top_hits");
|
|
REQUIRE(hits.size() == 2U);
|
|
CHECK(hits.at(0).at("doc_id") == "x2");
|
|
CHECK(hits.at(0).at("rank") == 1U);
|
|
CHECK(hits.at(0).at("score").get<double>() < 0.5);
|
|
CHECK(hits.at(1).at("doc_id") == "x1");
|
|
CHECK(debug.at("topology_candidate_rescue_exact_candidate_hashes_requested") == "2");
|
|
CHECK(debug.at("topology_candidate_rescue_exact_rows_visited_status") == "observed");
|
|
CHECK(debug.at("topology_candidate_rescue_exact_rows_visited") == "2");
|
|
CHECK(debug.at("topology_candidate_rescue_exact_distance_evaluations_status") == "observed");
|
|
CHECK(debug.at("topology_candidate_rescue_exact_distance_evaluations") == "2");
|
|
CHECK(debug.at("topology_candidate_rescue_exact_returned_rows") == "2");
|
|
CHECK(debug.at("topology_candidate_rescue_exact_matched_documents") == "2");
|
|
CHECK(debug.at("topology_candidate_rescue_exact_matched_document_hashes") == "x1\tx2");
|
|
CHECK(debug.at("topology_candidate_rescue_exact_matched_document_doc_ids") == "x1\tx2");
|
|
}
|
|
|
|
TEST_CASE("SearchEngine topology final-window rescue promotes a topology-scored candidate",
|
|
"[search][topology][candidate-rescue][final-rescue][catch2]") {
|
|
yams::test::ScopedEnvVar stageTrace("YAMS_SEARCH_STAGE_TRACE", std::optional<std::string>("1"));
|
|
|
|
TopologySearchFixture fix{vector::VectorSearchEngine::SimeonPqAdc};
|
|
fix.addDocument("x1", "alpha one", {1.0F, 0.0F});
|
|
// x2 is topology-reachable from x1 (see seedTwoClusterTopology) but its real vector embedding
|
|
// is deliberately dissimilar to the query, mirroring the q0/q37 boundary: relation-reachable
|
|
// and document-scored, but losing on raw fused score alone.
|
|
fix.addDocument("x2", "alpha two", {0.1F, 0.9F});
|
|
// x3 is a strong organic match (like x1) but has no topology relation at all, so it competes
|
|
// for the same final-window slot as x2 without ever receiving topology evidence.
|
|
fix.addDocument("x3", "alpha one", {0.95F, 0.05F});
|
|
fix.addDocument("y1", "omega one", {0.0F, 1.0F});
|
|
fix.addDocument("y2", "omega two", {0.1F, 0.9F});
|
|
seedTwoClusterTopology(fix);
|
|
REQUIRE(fix.vectorDb->buildIndex());
|
|
auto generator = makeFixedGenerator({1.0F, 0.0F});
|
|
|
|
auto config = topologyRoutingTestConfig(true);
|
|
config.topologyRoutingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
config.topologyVectorPolicy = SearchEngineConfig::TopologyVectorPolicy::Augment;
|
|
config.topologySparseDenseAlpha = 1.0F;
|
|
config.topologyNarrowMinBoundaryMargin = 0.0F;
|
|
config.textMaxResults = 2;
|
|
config.vectorMaxResults = 2;
|
|
config.enableWeakQueryFanoutBoost = false;
|
|
config.topologyMaxDocs = 2;
|
|
config.topologyExpansionOutputLimit = 2;
|
|
config.similarityThreshold = -1.0F;
|
|
|
|
// Default: no final-window rescue. The window's 2 slots go to the strong organic matches
|
|
// (x1, x3); the weak, topology-only x2 is excluded even though it was admitted and scored as
|
|
// a candidate.
|
|
auto baseline = runTopologySearch(fix, generator, config, 2, "alpha one");
|
|
REQUIRE(baseline.has_value());
|
|
REQUIRE(baseline.value().results.size() == 2);
|
|
std::vector<std::string> baselineHashes;
|
|
for (const auto& r : baseline.value().results) {
|
|
baselineHashes.push_back(r.document.sha256Hash);
|
|
}
|
|
CHECK(std::ranges::find(baselineHashes, "x1") != baselineHashes.end());
|
|
CHECK(std::ranges::find(baselineHashes, "x3") != baselineHashes.end());
|
|
CHECK(std::ranges::find(baselineHashes, "x2") == baselineHashes.end());
|
|
CHECK(baseline.value().debugStats.at("topology_final_rescue_enabled") == "0");
|
|
|
|
// Enabling the topology final-window rescue seam and requiring both window slots to carry
|
|
// topology evidence promotes x2 (which has it) over x3 (which does not), independent of the
|
|
// generic evidence-rescue family (fusionEvidenceRescueSlots), which stays disabled here.
|
|
config.topologyFinalRescueSlots = 2;
|
|
auto rescued = runTopologySearch(fix, generator, config, 2, "alpha one");
|
|
REQUIRE(rescued.has_value());
|
|
REQUIRE(rescued.value().results.size() == 2);
|
|
std::vector<std::string> rescuedHashes;
|
|
for (const auto& r : rescued.value().results) {
|
|
rescuedHashes.push_back(r.document.sha256Hash);
|
|
}
|
|
CHECK(std::ranges::find(rescuedHashes, "x1") != rescuedHashes.end());
|
|
CHECK(std::ranges::find(rescuedHashes, "x2") != rescuedHashes.end());
|
|
CHECK(std::ranges::find(rescuedHashes, "x3") == rescuedHashes.end());
|
|
|
|
const auto& debug = rescued.value().debugStats;
|
|
CHECK(debug.at("topology_final_rescue_enabled") == "1");
|
|
CHECK(debug.at("topology_final_rescue_slots") == "2");
|
|
CHECK(debug.at("topology_final_rescue_promoted_doc_ids") == "x2");
|
|
CHECK(debug.at("topology_final_rescue_displaced_doc_ids") == "x3");
|
|
CHECK(debug.at("topology_final_rescue_eligible_window_doc_ids").empty());
|
|
CHECK(debug.at("topology_final_rescue_eligible_tail_doc_ids") == "x2");
|
|
|
|
// x1 is organically found (not a novel rescue) but still shares the topology route with x2,
|
|
// so it also receives nonzero topologyScore -- a magnitude-only gate would have considered it
|
|
// just as eligible as x2. It must never appear as promoted/displaced: the eligibility set is
|
|
// scoped to novel candidates only, not every topology-touched one.
|
|
const auto x1Result = std::ranges::find_if(
|
|
rescued.value().results, [](const auto& r) { return r.document.sha256Hash == "x1"; });
|
|
REQUIRE(x1Result != rescued.value().results.end());
|
|
CHECK(x1Result->topologyScore.has_value());
|
|
CHECK(debug.at("topology_final_rescue_promoted_doc_ids").find("x1") == std::string::npos);
|
|
CHECK(debug.at("topology_final_rescue_displaced_doc_ids").find("x1") == std::string::npos);
|
|
}
|
|
|
|
namespace {
|
|
|
|
// x2 shares a topology route with the lexical seed x1, but lexical Tier-1 keeps only x1
|
|
// (textMaxResults=1) and the global vector top-1 is y1 (query embedding {0,1}). x2 is therefore
|
|
// reachable only through topology routing.
|
|
SearchEngineConfig routedOnlyCandidateConfig(SearchEngineConfig::TopologyVectorPolicy policy) {
|
|
auto config = topologyRoutingTestConfig(true);
|
|
config.topologyRoutingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
config.topologyVectorPolicy = policy;
|
|
config.topologySparseDenseAlpha = 1.0F;
|
|
config.topologyNarrowMinBoundaryMargin = 0.0F;
|
|
config.textMaxResults = 1;
|
|
config.vectorMaxResults = 1;
|
|
config.enableWeakQueryFanoutBoost = false;
|
|
config.topologyMaxDocs = 2;
|
|
config.topologyExpansionOutputLimit = 2;
|
|
config.similarityThreshold = -1.0F;
|
|
return config;
|
|
}
|
|
|
|
TopologyAssistStageResult
|
|
runRoutedOnlyAssistStage(TopologySearchFixture& fix,
|
|
SearchEngineConfig::TopologyVectorPolicy policy) {
|
|
TopologyAssistStageRequest request;
|
|
request.query = "alpha one";
|
|
request.config = routedOnlyCandidateConfig(policy);
|
|
request.routingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
request.weakTier1Query = true;
|
|
request.tier1SeedHashes = {"x1"};
|
|
request.tier1SeedEvidence = {{.documentHash = "x1", .weight = 1.0F}};
|
|
request.existingCandidateHashes = {"x1"};
|
|
request.queryEmbedding = std::vector<float>{0.0F, 1.0F};
|
|
request.queryEmbeddingSpaceIdentity = "test-space-v1";
|
|
request.metadataRepo = fix.repo;
|
|
request.kgStore = fix.kgStore;
|
|
return runTopologyAssistStage(request);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("Topology assist stage reports routed members added under augment only",
|
|
"[search][topology][augment][catch2]") {
|
|
const TopologyVectorEnvPin vectorEnv;
|
|
TopologySearchFixture fix{vector::VectorSearchEngine::SimeonPqAdc};
|
|
seedTopologyDocuments(fix);
|
|
seedTwoClusterTopology(fix);
|
|
|
|
SECTION("augment counts allowed members that are new to the candidate pool") {
|
|
const auto stage =
|
|
runRoutedOnlyAssistStage(fix, SearchEngineConfig::TopologyVectorPolicy::Augment);
|
|
const auto& session = stage.session;
|
|
REQUIRE(session.applied);
|
|
CHECK(stage.skipReason.empty());
|
|
CHECK(session.certificate.allowedDocumentHashes ==
|
|
std::unordered_set<std::string>{"x1", "x2"});
|
|
CHECK(session.routedDocs == 2U);
|
|
CHECK(session.addedCandidates == 1U);
|
|
CHECK(session.duplicateCandidates == 1U);
|
|
CHECK(session.addedCandidateHashes == std::vector<std::string>{"x2"});
|
|
}
|
|
|
|
for (const auto policy : {SearchEngineConfig::TopologyVectorPolicy::Narrow,
|
|
SearchEngineConfig::TopologyVectorPolicy::Shadow}) {
|
|
DYNAMIC_SECTION("policy " << SearchEngineConfig::topologyVectorPolicyToString(policy)
|
|
<< " keeps the allowed set without claiming additions") {
|
|
const auto stage = runRoutedOnlyAssistStage(fix, policy);
|
|
const auto& session = stage.session;
|
|
REQUIRE(session.applied);
|
|
CHECK(session.certificate.allowedDocumentHashes ==
|
|
std::unordered_set<std::string>{"x1", "x2"});
|
|
CHECK(session.routedDocs == 2U);
|
|
CHECK(session.addedCandidates == 0U);
|
|
CHECK(session.duplicateCandidates == 0U);
|
|
CHECK(session.addedCandidateHashes.empty());
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("SearchEngine topology augment adds a routed-only document to the fused results",
|
|
"[search][topology][augment][catch2]") {
|
|
const TopologyVectorEnvPin vectorEnv;
|
|
TopologySearchFixture fix{vector::VectorSearchEngine::SimeonPqAdc};
|
|
seedTopologyDocuments(fix);
|
|
seedTwoClusterTopology(fix);
|
|
REQUIRE(fix.vectorDb->buildIndex());
|
|
auto generator = makeFixedGenerator({0.0F, 1.0F});
|
|
|
|
auto disabledConfig =
|
|
routedOnlyCandidateConfig(SearchEngineConfig::TopologyVectorPolicy::Shadow);
|
|
disabledConfig.topologyRoutingMode = SearchEngineConfig::TopologyRoutingMode::Disabled;
|
|
auto disabled = runTopologySearch(fix, generator, disabledConfig, 4, "alpha one");
|
|
REQUIRE(disabled.has_value());
|
|
const auto disabledHashes = resultHashes(disabled.value());
|
|
// Precondition: without topology the routed-only document is unreachable.
|
|
REQUIRE(std::ranges::find(disabledHashes, "x2") == disabledHashes.end());
|
|
|
|
SECTION("augment") {
|
|
auto response = runTopologySearch(
|
|
fix, generator,
|
|
routedOnlyCandidateConfig(SearchEngineConfig::TopologyVectorPolicy::Augment), 4,
|
|
"alpha one");
|
|
REQUIRE(response.has_value());
|
|
const auto routed = std::ranges::find_if(
|
|
response.value().results, [](const auto& r) { return r.document.sha256Hash == "x2"; });
|
|
REQUIRE(routed != response.value().results.end());
|
|
CHECK(routed->score > 0.0);
|
|
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK(debug.at("topology_vector_policy") == "augment");
|
|
CHECK(debug.at("topology_weak_query_applied") == "1");
|
|
CHECK(debug.at("topology_weak_query_skip_reason").empty());
|
|
CHECK(debug.at("topology_weak_query_added_candidates") == "1");
|
|
CHECK(debug.at("topology_weak_query_duplicate_candidates") == "1");
|
|
CHECK(debug.at("topology_weak_query_total_candidates") == "2");
|
|
CHECK(debug.at("topology_candidate_rescue_applied") == "1");
|
|
CHECK(debug.at("topology_weak_query_narrow_applied") == "0");
|
|
}
|
|
|
|
SECTION("shadow matches topology-disabled results") {
|
|
auto response = runTopologySearch(
|
|
fix, generator,
|
|
routedOnlyCandidateConfig(SearchEngineConfig::TopologyVectorPolicy::Shadow), 4,
|
|
"alpha one");
|
|
REQUIRE(response.has_value());
|
|
REQUIRE(response.value().results.size() == disabled.value().results.size());
|
|
for (std::size_t i = 0; i < response.value().results.size(); ++i) {
|
|
CHECK(response.value().results[i].document.sha256Hash ==
|
|
disabled.value().results[i].document.sha256Hash);
|
|
CHECK(response.value().results[i].score == disabled.value().results[i].score);
|
|
}
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK(debug.at("topology_shadow_evaluated") == "1");
|
|
CHECK(debug.at("topology_weak_query_applied") == "0");
|
|
CHECK(debug.at("topology_weak_query_added_candidates") == "0");
|
|
CHECK(debug.at("topology_weak_query_duplicate_candidates") == "0");
|
|
CHECK(debug.at("topology_weak_query_total_candidates") == "1");
|
|
}
|
|
|
|
SECTION("narrow without a certificate stays global") {
|
|
auto response = runTopologySearch(
|
|
fix, generator,
|
|
routedOnlyCandidateConfig(SearchEngineConfig::TopologyVectorPolicy::Narrow), 4,
|
|
"alpha one");
|
|
REQUIRE(response.has_value());
|
|
const auto hashes = resultHashes(response.value());
|
|
CHECK(std::ranges::find(hashes, "x2") == hashes.end());
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK(debug.at("topology_vector_policy") == "narrow");
|
|
CHECK(debug.at("topology_weak_query_applied") == "1");
|
|
CHECK(debug.at("topology_route_admission_eligible") == "0");
|
|
CHECK(debug.at("topology_weak_query_narrow_applied") == "0");
|
|
CHECK(debug.at("topology_weak_query_added_candidates") == "0");
|
|
CHECK(debug.at("topology_weak_query_duplicate_candidates") == "0");
|
|
CHECK(debug.at("topology_weak_query_total_candidates") == "1");
|
|
CHECK(debug.at("topology_candidate_rescue_attempted") == "0");
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Evidence pipeline receives no topology adjustments under a shadow policy",
|
|
"[search][topology][evidence_pipeline][catch2]") {
|
|
TopologySearchFixture fix{vector::VectorSearchEngine::Vec0L2};
|
|
seedTopologyDocuments(fix);
|
|
seedTwoClusterTopology(fix);
|
|
auto generator = makeFixedGenerator({0.0F, 1.0F});
|
|
|
|
SearchExecutionContext context = defaultSearchExecutionContext();
|
|
context.freshness.lexicalReady = true;
|
|
context.freshness.vectorReady = true;
|
|
context.freshness.kgReady = true;
|
|
context.freshness.topologyReady = true;
|
|
SearchExecutionContextGuard contextGuard(context);
|
|
|
|
auto globalConfig = topologyRoutingTestConfig(false);
|
|
SearchEngine globalEngine(fix.repo, fix.vectorDb, generator, fix.kgStore, globalConfig);
|
|
|
|
auto evidenceConfig = topologyRoutingTestConfig(true);
|
|
evidenceConfig.topologyRoutingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
evidenceConfig.topologyVectorPolicy = SearchEngineConfig::TopologyVectorPolicy::Shadow;
|
|
evidenceConfig.topologySparseDenseAlpha = 0.0F;
|
|
evidenceConfig.topologyNarrowMinBoundaryMargin = 0.0F;
|
|
SearchEngine evidenceEngine(fix.repo, fix.vectorDb, generator, fix.kgStore, evidenceConfig);
|
|
|
|
SearchParams params;
|
|
params.limit = 4;
|
|
auto global = globalEngine.searchWithResponse("unmatched query", params);
|
|
auto evidence = evidenceEngine.searchWithResponse("unmatched query", params);
|
|
REQUIRE(global.has_value());
|
|
REQUIRE(evidence.has_value());
|
|
|
|
std::vector<std::string> globalHashes;
|
|
std::vector<std::string> evidenceHashes;
|
|
for (const auto& result : global.value().results) {
|
|
globalHashes.push_back(result.document.sha256Hash);
|
|
}
|
|
for (const auto& result : evidence.value().results) {
|
|
evidenceHashes.push_back(result.document.sha256Hash);
|
|
}
|
|
std::ranges::sort(globalHashes);
|
|
std::ranges::sort(evidenceHashes);
|
|
CHECK(evidenceHashes == globalHashes);
|
|
|
|
const auto& debug = evidence.value().debugStats;
|
|
CHECK(debug.at("topology_vector_policy") == "shadow");
|
|
CHECK(debug.at("topology_weak_query_applied") == "0");
|
|
CHECK(debug.at("topology_weak_query_narrow_applied") == "0");
|
|
CHECK(debug.at("topology_vector_filter_applied") == "0");
|
|
CHECK(debug.at("topology_vector_allowed_set_ann_applied") == "0");
|
|
CHECK_FALSE(debug.contains("topology_shadow_retained_candidate_doc_ids"));
|
|
CHECK_FALSE(debug.contains("topology_shadow_removed_candidate_doc_ids"));
|
|
CHECK_FALSE(debug.contains("trace_stage_summary_json"));
|
|
CHECK_FALSE(debug.contains("trace_fusion_source_summary_json"));
|
|
CHECK(debug.at("candidate_pipeline_topology_annotated_candidates") == "0");
|
|
}
|
|
|
|
TEST_CASE("Hybrid topology keeps lexical hits while uncertified narrowing falls back",
|
|
"[search][topology][narrowing][admission][catch2]") {
|
|
TopologySearchFixture fix;
|
|
seedTopologyDocuments(fix);
|
|
fix.addDocument("lexical-only", "unique needle", {1.0F, 0.0F});
|
|
seedTwoClusterTopology(fix);
|
|
|
|
auto config = topologyRoutingTestConfig(true);
|
|
config.topologyRoutingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
config.topologySparseDenseAlpha = 0.0F;
|
|
auto generator = makeFixedGenerator({0.0F, 1.0F});
|
|
|
|
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("unique needle", params);
|
|
REQUIRE(response.has_value());
|
|
|
|
const auto& debug = response.value().debugStats;
|
|
REQUIRE(debug.at("topology_weak_query_narrow_applied") == "0");
|
|
CHECK(debug.at("topology_route_narrow_proposed") == "1");
|
|
CHECK(debug.at("topology_route_admission_eligible") == "0");
|
|
CHECK(debug.at("topology_weak_query_allowed_candidates") == "2");
|
|
CHECK(debug.at("topology_vector_filter_applied") == "0");
|
|
CHECK_FALSE(debug.contains("topology_vector_scores_reused"));
|
|
CHECK_FALSE(debug.contains("topology_member_rerank_distance_evaluations_actual"));
|
|
|
|
const auto lexical = std::find_if(
|
|
response.value().results.begin(), response.value().results.end(),
|
|
[](const auto& result) { return result.document.sha256Hash == "lexical-only"; });
|
|
REQUIRE(lexical != response.value().results.end());
|
|
}
|
|
|
|
TEST_CASE("SearchEngine topology routing loads stored artifacts without freshness readiness",
|
|
"[search][topology][catch2]") {
|
|
TopologySearchFixture fix;
|
|
seedTopologyDocuments(fix);
|
|
seedTwoClusterTopology(fix);
|
|
auto generator = makeFixedGenerator({0.0F, 1.0F});
|
|
|
|
SearchEngineConfig config = topologyRoutingTestConfig(true);
|
|
|
|
SearchExecutionContext context = defaultSearchExecutionContext();
|
|
context.freshness.lexicalReady = true;
|
|
context.freshness.vectorReady = true;
|
|
context.freshness.kgReady = true;
|
|
context.freshness.topologyReady = false;
|
|
context.freshness.topologyArtifactsFresh = false;
|
|
context.freshness.topologyEpoch = 0;
|
|
SearchExecutionContextGuard contextGuard(context);
|
|
|
|
SearchEngine engine(fix.repo, fix.vectorDb, generator, fix.kgStore, config);
|
|
SearchParams params;
|
|
params.limit = 4;
|
|
auto response = engine.searchWithResponse("zz", params);
|
|
REQUIRE(response.has_value());
|
|
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK(debug.at("topology_routing_freshness_gate") == "1");
|
|
CHECK((debug.at("topology_routing_mode") == "weak_query_only"));
|
|
CHECK((debug.at("topology_weak_query_enabled") == "1"));
|
|
CHECK((debug.at("topology_weak_query_load_attempted") == "1"));
|
|
CHECK((debug.at("topology_weak_query_load_succeeded") == "1"));
|
|
CHECK((debug.at("topology_ready") == "1"));
|
|
CHECK((debug.at("topology_artifacts_fresh") == "1"));
|
|
CHECK(debug.contains("topology_epoch"));
|
|
CHECK(debug.at("topology_weak_query_skip_reason").empty());
|
|
CHECK((debug.at("topology_weak_query_applied") == "1"));
|
|
CHECK((debug.at("topology_weak_query_added_candidates") == "0"));
|
|
}
|
|
|
|
TEST_CASE("SearchEngine abstains from topology routing when daemon artifacts are stale",
|
|
"[search][topology][freshness][catch2]") {
|
|
TopologySearchFixture fix;
|
|
seedTopologyDocuments(fix);
|
|
seedTwoClusterTopology(fix);
|
|
auto generator = makeFixedGenerator({0.0F, 1.0F});
|
|
|
|
SearchEngineConfig config = topologyRoutingTestConfig(true);
|
|
|
|
SearchExecutionContext context = defaultSearchExecutionContext();
|
|
context.freshness.lexicalReady = true;
|
|
context.freshness.vectorReady = true;
|
|
context.freshness.kgReady = true;
|
|
context.freshness.topologyStatusKnown = true;
|
|
context.freshness.topologyEpoch = 7;
|
|
context.freshness.topologyReady = false;
|
|
context.freshness.topologyArtifactsFresh = false;
|
|
context.freshness.topologyDirtyDocuments = 1;
|
|
SearchExecutionContextGuard contextGuard(context);
|
|
|
|
SearchEngine engine(fix.repo, fix.vectorDb, generator, fix.kgStore, config);
|
|
SearchParams params;
|
|
params.limit = 4;
|
|
auto response = engine.searchWithResponse("zz", params);
|
|
REQUIRE(response.has_value());
|
|
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK(debug.at("topology_routing_freshness_gate") == "0");
|
|
CHECK(debug.at("topology_routing_mode") == "disabled");
|
|
CHECK(debug.at("topology_weak_query_load_attempted") == "0");
|
|
CHECK(debug.at("topology_weak_query_load_succeeded") == "0");
|
|
CHECK(debug.at("topology_weak_query_skip_reason") == "artifacts_not_fresh");
|
|
CHECK(debug.at("topology_weak_query_applied") == "0");
|
|
CHECK(debug.at("topology_weak_query_narrow_applied") == "0");
|
|
}
|
|
|
|
TEST_CASE("SearchEngine rejects inconsistent topology artifacts before routing",
|
|
"[search][topology][catch2]") {
|
|
TopologySearchFixture fix;
|
|
seedTopologyDocuments(fix);
|
|
seedTopologyWithStaleClusterMember(fix);
|
|
auto generator = makeFixedGenerator({0.0F, 1.0F});
|
|
|
|
SearchEngineConfig config = topologyRoutingTestConfig(true);
|
|
|
|
SearchExecutionContext context = defaultSearchExecutionContext();
|
|
context.freshness.lexicalReady = true;
|
|
context.freshness.vectorReady = true;
|
|
context.freshness.kgReady = true;
|
|
context.freshness.topologyReady = true;
|
|
context.freshness.topologyArtifactsFresh = true;
|
|
SearchExecutionContextGuard contextGuard(context);
|
|
|
|
SearchEngine engine(fix.repo, fix.vectorDb, generator, fix.kgStore, config);
|
|
SearchParams params;
|
|
params.limit = 4;
|
|
auto response = engine.searchWithResponse("zz", params);
|
|
REQUIRE(response.has_value());
|
|
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK((debug.at("topology_weak_query_load_attempted") == "1"));
|
|
CHECK((debug.at("topology_weak_query_load_succeeded") == "1"));
|
|
CHECK((debug.at("topology_artifact_admitted") == "0"));
|
|
CHECK((debug.at("topology_weak_query_skip_reason").rfind("invalid_artifact:", 0) == 0));
|
|
CHECK((debug.at("topology_weak_query_applied") == "0"));
|
|
CHECK((debug.at("topology_weak_query_added_candidates") == "0"));
|
|
}
|
|
|
|
TEST_CASE("SearchEngine disabled topology routing does not load or add candidates",
|
|
"[search][topology][catch2]") {
|
|
TopologySearchFixture fix;
|
|
seedTopologyDocuments(fix);
|
|
seedTwoClusterTopology(fix);
|
|
auto generator = makeFixedGenerator({0.0F, 1.0F});
|
|
|
|
SearchEngineConfig config = topologyRoutingTestConfig(false);
|
|
|
|
SearchExecutionContext context = defaultSearchExecutionContext();
|
|
context.freshness.lexicalReady = true;
|
|
context.freshness.vectorReady = true;
|
|
context.freshness.kgReady = true;
|
|
SearchExecutionContextGuard contextGuard(context);
|
|
|
|
SearchEngine engine(fix.repo, fix.vectorDb, generator, fix.kgStore, config);
|
|
SearchParams params;
|
|
params.limit = 4;
|
|
auto response = engine.searchWithResponse("zz", params);
|
|
REQUIRE(response.has_value());
|
|
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK((debug.at("topology_routing_mode") == "disabled"));
|
|
CHECK((debug.at("topology_weak_query_enabled") == "0"));
|
|
CHECK((debug.at("topology_weak_query_load_attempted") == "0"));
|
|
CHECK((debug.at("topology_weak_query_load_succeeded") == "0"));
|
|
CHECK((debug.at("topology_weak_query_skip_reason") == "disabled"));
|
|
CHECK((debug.at("topology_weak_query_applied") == "0"));
|
|
CHECK((debug.at("topology_weak_query_narrow_applied") == "0"));
|
|
CHECK((debug.at("topology_weak_query_added_candidates") == "0"));
|
|
}
|
|
|
|
TEST_CASE("SearchEngine weak-only topology routing skips strong tier-1 queries",
|
|
"[search][topology][catch2]") {
|
|
TopologySearchFixture fix;
|
|
seedTopologyDocuments(fix);
|
|
seedTwoClusterTopology(fix);
|
|
auto generator = makeFixedGenerator({0.0F, 1.0F});
|
|
|
|
SearchEngineConfig config = topologyRoutingTestConfig(true);
|
|
config.weakQueryMinTextHits = 1;
|
|
config.weakQueryMinTopTextScore = 0.0F;
|
|
|
|
SearchExecutionContext context = defaultSearchExecutionContext();
|
|
context.freshness.lexicalReady = true;
|
|
context.freshness.vectorReady = true;
|
|
context.freshness.kgReady = true;
|
|
context.freshness.topologyReady = true;
|
|
context.freshness.topologyArtifactsFresh = true;
|
|
context.freshness.topologyEpoch = 7;
|
|
SearchExecutionContextGuard contextGuard(context);
|
|
|
|
SearchEngine engine(fix.repo, fix.vectorDb, generator, fix.kgStore, config);
|
|
SearchParams params;
|
|
params.limit = 4;
|
|
auto response = engine.searchWithResponse("alpha", params);
|
|
REQUIRE(response.has_value());
|
|
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK((debug.at("topology_routing_mode") == "weak_query_only"));
|
|
CHECK((debug.at("topology_weak_query_enabled") == "1"));
|
|
CHECK((debug.at("topology_weak_query_load_attempted") == "0"));
|
|
CHECK((debug.at("topology_weak_query_load_succeeded") == "0"));
|
|
CHECK((debug.at("topology_weak_query_skip_reason") == "strong_tier1_query"));
|
|
CHECK((debug.at("topology_weak_query_applied") == "0"));
|
|
CHECK((debug.at("topology_weak_query_added_candidates") == "0"));
|
|
}
|
|
|
|
TEST_CASE("SearchEngine global fallback vector-scores outside-route lexical hits",
|
|
"[search][topology][admission][catch2]") {
|
|
TopologySearchFixture fix;
|
|
seedTopologyDocuments(fix);
|
|
fix.addDocument("z1", "alpha outside route", {0.0F, 1.0F});
|
|
seedTwoClusterTopology(fix);
|
|
auto generator = makeFixedGenerator({0.0F, 1.0F});
|
|
|
|
SearchEngineConfig config = topologyRoutingTestConfig(true);
|
|
config.topologyRoutingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
config.weakQueryMinTextHits = 1;
|
|
config.weakQueryMinTopTextScore = 0.0F;
|
|
|
|
SearchExecutionContext context = defaultSearchExecutionContext();
|
|
context.freshness.lexicalReady = true;
|
|
context.freshness.vectorReady = true;
|
|
context.freshness.kgReady = true;
|
|
SearchExecutionContextGuard contextGuard(context);
|
|
|
|
SearchEngine engine(fix.repo, fix.vectorDb, generator, fix.kgStore, config);
|
|
SearchParams params;
|
|
params.limit = 4;
|
|
auto response = engine.searchWithResponse("alpha", params);
|
|
REQUIRE(response.has_value());
|
|
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK((debug.at("topology_routing_mode") == "hybrid_assist"));
|
|
CHECK((debug.at("topology_weak_query_load_attempted") == "1"));
|
|
CHECK((debug.at("topology_weak_query_load_succeeded") == "1"));
|
|
CHECK(debug.at("topology_weak_query_skip_reason").empty());
|
|
CHECK((debug.at("topology_weak_query_applied") == "1"));
|
|
CHECK((debug.at("topology_weak_query_narrow_applied") == "0"));
|
|
CHECK((debug.at("topology_route_narrow_proposed") == "1"));
|
|
CHECK((debug.at("topology_route_admission_eligible") == "0"));
|
|
CHECK((debug.at("topology_weak_query_allowed_candidates") == "2"));
|
|
|
|
const auto outsideRoute =
|
|
std::find_if(response.value().results.begin(), response.value().results.end(),
|
|
[](const auto& result) { return result.document.sha256Hash == "z1"; });
|
|
REQUIRE((outsideRoute != response.value().results.end()));
|
|
CHECK(outsideRoute->vectorScore.has_value());
|
|
|
|
const bool hasTopologyVectorScore = std::any_of(
|
|
response.value().results.begin(), response.value().results.end(), [](const auto& result) {
|
|
return result.graphVectorScore.has_value() && result.graphVectorScore.value() > 0.0;
|
|
});
|
|
CHECK_FALSE(hasTopologyVectorScore);
|
|
}
|
|
|
|
TEST_CASE("SearchEngine stage trace reports reranker flags as booleans",
|
|
"[search][trace][rerank][catch2]") {
|
|
yams::test::ScopedEnvVar stageTrace("YAMS_SEARCH_STAGE_TRACE", std::optional<std::string>("1"));
|
|
yams::test::ScopedEnvVar traceTopN("YAMS_SEARCH_STAGE_TRACE_TOP_N",
|
|
std::optional<std::string>("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.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_rerank_guard_score_gap"));
|
|
REQUIRE(debug.contains("trace_stage_summary_json"));
|
|
REQUIRE(debug.contains("trace_fusion_source_summary_json"));
|
|
CHECK((debug.at("trace_rerank_guard_score_gap") == "0.000000"));
|
|
}
|
|
|
|
TEST_CASE("SearchEngine exposes the topology evidence pipeline as its only frontend",
|
|
"[search][evidence_pipeline][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.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 = false;
|
|
|
|
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);
|
|
REQUIRE(response.value().results.size() == 2);
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK(debug.at("search_pipeline_name") == "topology_evidence");
|
|
CHECK(std::stoull(debug.at("candidate_pipeline_input_components")) > 0);
|
|
CHECK(std::stoull(debug.at("candidate_pipeline_aggregated_candidates")) >=
|
|
response.value().results.size());
|
|
CHECK(debug.at("candidate_pipeline_topology_annotated_candidates") == "0");
|
|
}
|
|
|
|
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.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<std::string>& documents) -> Result<std::vector<float>> {
|
|
std::vector<float> 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"));
|
|
}
|
|
|
|
TEST_CASE("SearchEngine reports the scoring mode of the wired cross reranker",
|
|
"[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.enableGraphRerank = false;
|
|
config.enableReranking = true;
|
|
config.rerankTopK = 2;
|
|
// The daemon resolves the scoring mode from reranker policy, which can differ from the
|
|
// engine config; the debug stat must describe the scorer that actually ran.
|
|
config.simeonRerankOuterMaxSim = false;
|
|
|
|
SearchExecutionContext context = defaultSearchExecutionContext();
|
|
context.freshness.lexicalReady = true;
|
|
SearchExecutionContextGuard contextGuard(context);
|
|
|
|
SearchEngine engine(fix.repo, fix.vectorDb, nullptr, fix.kgStore, config);
|
|
auto scorer = [](const std::string&,
|
|
const std::vector<std::string>& documents) -> Result<std::vector<float>> {
|
|
return std::vector<float>(documents.size(), 0.5F);
|
|
};
|
|
|
|
SearchParams params;
|
|
params.limit = 2;
|
|
|
|
SECTION("mode supplied by the wiring wins over the engine config") {
|
|
engine.setCrossReranker(scorer, "simeon_outer_maxsim");
|
|
auto response = engine.searchWithResponse("alpha", params);
|
|
REQUIRE(response.has_value());
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK((debug.at("cross_rerank_scoring_mode") == "simeon_outer_maxsim"));
|
|
CHECK((debug.at("cross_rerank_simeon_outer_maxsim") == "1"));
|
|
}
|
|
|
|
SECTION("an unlabelled scorer reports an unknown mode") {
|
|
engine.setCrossReranker(scorer);
|
|
auto response = engine.searchWithResponse("alpha", params);
|
|
REQUIRE(response.has_value());
|
|
const auto& debug = response.value().debugStats;
|
|
CHECK((debug.at("cross_rerank_scoring_mode") == "unknown"));
|
|
CHECK((debug.at("cross_rerank_simeon_outer_maxsim") == "0"));
|
|
}
|
|
}
|
|
|
|
// NOLINTEND(cppcoreguidelines-avoid-do-while, readability-function-cognitive-complexity)
|
|
|
|
TEST_CASE("Topology routing options preserve the typed product configuration",
|
|
"[search][topology][options][catch2]") {
|
|
SearchEngineConfig config;
|
|
config.topologyRouteScoringMode = SearchEngineConfig::TopologyRouteScoringMode::SeedCoverage;
|
|
config.topologyExpansionSource = SearchEngineConfig::TopologyExpansionSource::GraphNeighbors;
|
|
config.topologyMinClusters = 2;
|
|
config.topologyMaxClusters = 5;
|
|
config.topologyRoutingRepresentativeLimit = 3;
|
|
config.topologyRoutingAnnCandidateLimit = 19;
|
|
config.topologyAdaptiveProbeScoreGap = 0.07F;
|
|
config.topologyNarrowMinBoundaryMargin = 0.11F;
|
|
config.topologyMaxDocs = 17;
|
|
config.topologySparseDenseAlpha = 0.65F;
|
|
config.topologyMinRouteScore = 0.22F;
|
|
config.topologyRouteRiskCalibration.constructionFingerprint = "atlas-fingerprint";
|
|
config.topologyRouteWorkBudget.maxRowsVisited = 23;
|
|
config.topologyGraphNeighborMinScore = 0.31F;
|
|
config.topologyGraphNeighborReciprocalOnly = false;
|
|
config.topologyGraphWeightedSeedRanking = true;
|
|
|
|
const auto options =
|
|
makeTopologyRoutingOptions(config, SearchEngineConfig::TopologyRoutingMode::WeakQueryOnly,
|
|
/*weakTier1Query=*/true, /*collectRouteMembership=*/true);
|
|
|
|
CHECK(options.routingMode == SearchEngineConfig::TopologyRoutingMode::WeakQueryOnly);
|
|
CHECK(options.routeScoringMode == SearchEngineConfig::TopologyRouteScoringMode::SeedCoverage);
|
|
CHECK(options.expansionSource == SearchEngineConfig::TopologyExpansionSource::GraphNeighbors);
|
|
CHECK(options.weakTier1Query);
|
|
CHECK(options.minClusters == 2U);
|
|
CHECK(options.maxClusters == 5U);
|
|
CHECK(options.representativeLimit == 3U);
|
|
CHECK(options.denseAnnCandidateLimit == 19U);
|
|
CHECK(options.adaptiveProbeScoreGap == Catch::Approx(0.07F));
|
|
CHECK(options.narrowMinBoundaryMargin == Catch::Approx(0.11F));
|
|
CHECK(options.maxDocs == 17U);
|
|
CHECK(options.sparseDenseAlpha == Catch::Approx(0.65F));
|
|
CHECK(options.minRouteScore == Catch::Approx(0.22F));
|
|
CHECK(options.routeRiskCalibration.constructionFingerprint == "atlas-fingerprint");
|
|
CHECK(options.routeWorkBudget.maxRowsVisited == 23U);
|
|
CHECK(options.collectRouteMembership);
|
|
CHECK(options.graphNeighborMinScore == Catch::Approx(0.31F));
|
|
CHECK_FALSE(options.graphNeighborReciprocalOnly);
|
|
CHECK(options.graphWeightedSeedRanking);
|
|
|
|
// Default (shadow) policy reports no additions; only augment counts route members.
|
|
CHECK_FALSE(options.countRouteMemberAdditions);
|
|
config.topologyVectorPolicy = SearchEngineConfig::TopologyVectorPolicy::Narrow;
|
|
CHECK_FALSE(makeTopologyRoutingOptions(config, options.routingMode, true, true)
|
|
.countRouteMemberAdditions);
|
|
config.topologyVectorPolicy = SearchEngineConfig::TopologyVectorPolicy::Augment;
|
|
const auto augment = makeTopologyRoutingOptions(config, options.routingMode, true, true);
|
|
CHECK(augment.countRouteMemberAdditions);
|
|
CHECK_FALSE(makeTopologyRoutingOptions(config, options.routingMode, true, false)
|
|
.countRouteMemberAdditions);
|
|
// Accounting does not change the allowed-set policy identity used by route calibration.
|
|
CHECK(topologyRoutingPolicyFingerprint("representation", augment) ==
|
|
topologyRoutingPolicyFingerprint("representation", options));
|
|
}
|
|
|
|
TEST_CASE("Topology route admission requires the protected-relation proof obligations",
|
|
"[search][topology][admission][catch2]") {
|
|
TopologyRouteAdmission admission;
|
|
CHECK_FALSE(admission.eligibleForNarrowing());
|
|
CHECK(admission.denialReason() == "protected_relation_coverage_unavailable");
|
|
|
|
admission.protectedRelationCoverage = TopologyProofObligationStatus::Satisfied;
|
|
admission.coordinateSpaceAlignment = TopologyProofObligationStatus::Satisfied;
|
|
admission.protectedFibersRepresented = TopologyProofObligationStatus::Satisfied;
|
|
admission.certificateSaturatesProtectedFibers = TopologyProofObligationStatus::Satisfied;
|
|
admission.routeRisk = TopologyProofObligationStatus::Satisfied;
|
|
admission.work = TopologyProofObligationStatus::Satisfied;
|
|
admission.coverMaterialization = TopologyProofObligationStatus::Satisfied;
|
|
admission.selectedCoverDocuments = 2;
|
|
admission.materializedCoverDocuments = 2;
|
|
|
|
CHECK(admission.eligibleForTrialNarrowing());
|
|
CHECK(admission.eligibleForNarrowing());
|
|
CHECK(admission.denialReason().empty());
|
|
|
|
admission.protectedRelationCoverage = TopologyProofObligationStatus::Violated;
|
|
CHECK_FALSE(admission.eligibleForTrialNarrowing());
|
|
CHECK_FALSE(admission.eligibleForNarrowing());
|
|
CHECK(admission.denialReason() == "protected_relation_coverage_violated");
|
|
|
|
admission.protectedRelationCoverage = TopologyProofObligationStatus::Satisfied;
|
|
admission.protectedFibersRepresented = TopologyProofObligationStatus::Unavailable;
|
|
CHECK(admission.denialReason() == "protected_fibers_representation_unavailable");
|
|
|
|
admission.protectedFibersRepresented = TopologyProofObligationStatus::Satisfied;
|
|
admission.certificateSaturatesProtectedFibers = TopologyProofObligationStatus::Violated;
|
|
CHECK(admission.denialReason() == "protected_fiber_saturation_violated");
|
|
}
|
|
|
|
TEST_CASE("Topology route certificate owns the theorem action boundary",
|
|
"[search][topology][certificate][catch2]") {
|
|
TopologyRouteCertificate certificate;
|
|
CHECK(certificate.action() == TopologyRouteAction::Global);
|
|
CHECK_FALSE(certificate.eligibleForTrialNarrowing());
|
|
|
|
certificate.allowedDocumentHashes.insert("document");
|
|
CHECK(certificate.action() == TopologyRouteAction::Augment);
|
|
|
|
auto& admission = certificate.admission;
|
|
admission.coordinateSpaceAlignment = TopologyProofObligationStatus::Satisfied;
|
|
admission.protectedRelationCoverage = TopologyProofObligationStatus::Satisfied;
|
|
admission.protectedFibersRepresented = TopologyProofObligationStatus::Satisfied;
|
|
admission.certificateSaturatesProtectedFibers = TopologyProofObligationStatus::Satisfied;
|
|
admission.routeRisk = TopologyProofObligationStatus::Satisfied;
|
|
admission.coverMaterialization = TopologyProofObligationStatus::Satisfied;
|
|
admission.selectedCoverDocuments = 1;
|
|
admission.materializedCoverDocuments = 1;
|
|
|
|
CHECK_FALSE(certificate.isConstructionBound());
|
|
CHECK_FALSE(certificate.eligibleForTrialNarrowing());
|
|
certificate.constructionFingerprint = "construction";
|
|
certificate.selectedCoverIds = {"cover"};
|
|
certificate.selectedProtectedRelationFiberIds = {"fiber"};
|
|
CHECK(certificate.isConstructionBound());
|
|
CHECK(certificate.eligibleForTrialNarrowing());
|
|
CHECK(certificate.action() == TopologyRouteAction::Augment);
|
|
|
|
admission.work = TopologyProofObligationStatus::Satisfied;
|
|
CHECK(certificate.action() == TopologyRouteAction::Narrow);
|
|
}
|
|
|
|
TEST_CASE("Topology routing session reports route confidence and seed coverage",
|
|
"[search][topology][catch2]") {
|
|
TopologySearchFixture fix;
|
|
seedTopologyDocuments(fix);
|
|
seedTwoClusterTopology(fix);
|
|
|
|
TopologyRoutingSessionRequest request;
|
|
request.query = "omega";
|
|
request.seedDocumentHashes = {"y1", "not-in-any-cluster"};
|
|
request.queryEmbedding = std::vector<float>{0.0F, 1.0F};
|
|
request.options.routingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
request.options.weakTier1Query = true;
|
|
request.options.maxClusters = 2;
|
|
request.options.maxDocs = 8;
|
|
request.options.minRouteScore = 0.0F;
|
|
|
|
const auto session = runTopologyRoutingSession(request, fix.repo, fix.kgStore);
|
|
|
|
REQUIRE(session.artifactAdmitted);
|
|
REQUIRE((session.acceptedRoutes >= 1));
|
|
CHECK((session.bestRouteScore > 0.0F));
|
|
CHECK((session.meanAcceptedRouteScore > 0.0F));
|
|
CHECK((session.meanAcceptedRouteScore <= session.bestRouteScore));
|
|
CHECK((session.seedCount == 2));
|
|
CHECK((session.seedsInRoutedClusters == 1));
|
|
}
|
|
|
|
TEST_CASE("Topology routing session excludes min-score-rejected routes from confidence aggregates",
|
|
"[search][topology][catch2]") {
|
|
TopologySearchFixture fix;
|
|
seedTopologyDocuments(fix);
|
|
seedTwoClusterTopology(fix);
|
|
|
|
TopologyRoutingSessionRequest request;
|
|
request.query = "omega";
|
|
request.seedDocumentHashes = {"y1"};
|
|
request.queryEmbedding = std::vector<float>{0.0F, 1.0F};
|
|
request.options.routingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
request.options.weakTier1Query = true;
|
|
request.options.maxClusters = 2;
|
|
request.options.maxDocs = 8;
|
|
request.options.minRouteScore = 1000000.0F;
|
|
|
|
const auto session = runTopologyRoutingSession(request, fix.repo, fix.kgStore);
|
|
|
|
CHECK((session.routesRejected >= 1));
|
|
CHECK((session.acceptedRoutes == 0));
|
|
CHECK((session.bestRouteScore == 0.0F));
|
|
CHECK((session.meanAcceptedRouteScore == 0.0F));
|
|
CHECK((session.seedsInRoutedClusters == 0));
|
|
CHECK((session.addedCandidates == 0));
|
|
}
|
|
|
|
TEST_CASE("Topology routing expands through query-independent medoids",
|
|
"[search][topology][catch2]") {
|
|
TopologySearchFixture fix;
|
|
seedTopologyDocuments(fix);
|
|
seedTwoClusterTopology(fix);
|
|
|
|
TopologyRoutingSessionRequest request;
|
|
request.query = "omega";
|
|
request.seedDocumentHashes = {"y1"};
|
|
request.queryEmbedding = std::vector<float>{0.0F, 1.0F};
|
|
request.options.routingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
request.options.weakTier1Query = true;
|
|
request.options.maxClusters = 2;
|
|
request.options.maxDocs = 16;
|
|
const auto session = runTopologyRoutingSession(request, fix.repo, fix.kgStore);
|
|
REQUIRE(session.artifactAdmitted);
|
|
CHECK((session.addedCandidates <= session.acceptedRoutes));
|
|
CHECK((session.routedCandidateHashes.size() <= session.acceptedRoutes));
|
|
}
|
|
|
|
TEST_CASE("Topology routing refuses hard narrowing without complete theorem evidence",
|
|
"[search][topology][narrowing][admission][catch2]") {
|
|
TopologySearchFixture fix;
|
|
seedTopologyDocuments(fix);
|
|
seedTwoClusterTopology(fix);
|
|
|
|
TopologyRoutingSessionRequest request;
|
|
request.query = "omega";
|
|
request.seedDocumentHashes = {"y2"};
|
|
request.queryEmbedding = std::vector<float>{0.0F, 1.0F};
|
|
request.options.routingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
request.options.weakTier1Query = true;
|
|
request.options.maxClusters = 1;
|
|
request.options.maxDocs = 1;
|
|
request.options.collectRouteMembership = true;
|
|
|
|
const auto session = runTopologyRoutingSession(request, fix.repo, fix.kgStore);
|
|
|
|
REQUIRE(session.artifactAdmitted);
|
|
REQUIRE(session.certificate.hasUsefulRoute());
|
|
CHECK_FALSE(session.certificate.admission.eligibleForNarrowing());
|
|
CHECK(session.certificate.admission.coverMaterialization ==
|
|
TopologyProofObligationStatus::Violated);
|
|
CHECK(session.certificate.admission.certificateSaturatesProtectedFibers ==
|
|
TopologyProofObligationStatus::Violated);
|
|
CHECK(session.certificate.admission.selectedCoverDocuments == 2U);
|
|
CHECK(session.certificate.admission.materializedCoverDocuments == 1U);
|
|
CHECK(session.certificate.admission.denialReason() == "cover_materialization_incomplete");
|
|
CHECK(session.routedCandidateHashes.empty());
|
|
CHECK(session.routedDocs == 1U);
|
|
CHECK(session.certificate.allowedDocumentHashes.size() == 1U);
|
|
CHECK(session.addedCandidates == 0U);
|
|
CHECK(session.addedCandidateHashes.empty());
|
|
CHECK(session.certificate.allowedDocumentHashes.contains("y2"));
|
|
CHECK_FALSE(session.certificate.allowedDocumentHashes.contains("y1"));
|
|
CHECK(session.candidateStructureEvidence.size() == 1U);
|
|
REQUIRE(session.candidateStructureEvidence.contains("y2"));
|
|
CHECK(session.candidateStructureEvidence.at("y2").scaleAgreement == Catch::Approx(0.0F));
|
|
|
|
request.options.maxDocs = 0;
|
|
const auto unbounded = runTopologyRoutingSession(request, fix.repo, fix.kgStore);
|
|
REQUIRE(unbounded.certificate.hasUsefulRoute());
|
|
CHECK_FALSE(unbounded.certificate.admission.eligibleForNarrowing());
|
|
CHECK(unbounded.certificate.admission.coverMaterialization ==
|
|
TopologyProofObligationStatus::Satisfied);
|
|
CHECK(unbounded.certificate.admission.certificateSaturatesProtectedFibers ==
|
|
TopologyProofObligationStatus::Satisfied);
|
|
CHECK(unbounded.certificate.admission.selectedCoverDocuments == 2U);
|
|
CHECK(unbounded.certificate.admission.materializedCoverDocuments == 2U);
|
|
CHECK(unbounded.certificate.admission.denialReason() ==
|
|
"protected_fibers_representation_unavailable");
|
|
CHECK(unbounded.certificate.allowedDocumentHashes.size() == 2U);
|
|
CHECK(unbounded.certificate.allowedDocumentHashes.contains("y1"));
|
|
CHECK(unbounded.certificate.allowedDocumentHashes.contains("y2"));
|
|
CHECK_FALSE(unbounded.certificate.constructionFingerprint.empty());
|
|
REQUIRE(unbounded.certificate.selectedCoverIds.size() == 1U);
|
|
REQUIRE(unbounded.certificate.selectedProtectedRelationFiberIds.size() == 1U);
|
|
CHECK(unbounded.routedCandidateHashes.contains("y1"));
|
|
}
|
|
|
|
TEST_CASE("Topology routing exposes selected multiscale structural evidence from the snapshot",
|
|
"[search][topology][evidence_pipeline][multiscale][catch2]") {
|
|
TopologySearchFixture fix;
|
|
seedTopologyDocuments(fix);
|
|
auto batch = buildMultiscaleOverlapTopologyBatch();
|
|
|
|
TopologyRoutingSessionRequest request;
|
|
request.query = "omega";
|
|
request.seedDocumentHashes = {"y1"};
|
|
request.queryEmbedding = std::vector<float>{0.0F, 1.0F};
|
|
request.options.routingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
request.options.weakTier1Query = true;
|
|
request.options.maxClusters = 2;
|
|
request.options.maxDocs = 1;
|
|
request.options.collectRouteMembership = true;
|
|
request.snapshotCache = std::make_shared<TopologyRoutingSnapshotCache>([batch]() mutable {
|
|
return Result<std::optional<yams::topology::TopologyArtifactBatch>>{
|
|
std::optional<yams::topology::TopologyArtifactBatch>{std::move(batch)}};
|
|
});
|
|
|
|
const auto session = runTopologyRoutingSession(request, fix.repo, fix.kgStore);
|
|
|
|
REQUIRE(session.artifactAdmitted);
|
|
REQUIRE(session.certificate.allowedDocumentHashGroups.size() == 2U);
|
|
CHECK(session.certificate.allowedDocumentHashGroups[0].contains("y1"));
|
|
CHECK(session.certificate.allowedDocumentHashGroups[1].contains("y1"));
|
|
REQUIRE(session.candidateStructureEvidence.contains("y1"));
|
|
const auto& evidence = session.candidateStructureEvidence.at("y1");
|
|
CHECK(evidence.scaleAgreement == Catch::Approx(1.0F));
|
|
CHECK(evidence.overlapSupport > 0.0F);
|
|
CHECK(evidence.persistenceSupport > 0.0F);
|
|
CHECK(evidence.cohesionSupport > 0.0F);
|
|
CHECK(evidence.bridgeSupport > 0.0F);
|
|
CHECK(evidence.densitySupport > 0.0F);
|
|
}
|
|
|
|
TEST_CASE("Topology snapshot cache validates once and reuses the admitted epoch",
|
|
"[search][topology][cache][catch2]") {
|
|
auto batch = buildTwoClusterTopologyBatch();
|
|
int loadCalls = 0;
|
|
TopologyRoutingSnapshotCache cache([&]() {
|
|
++loadCalls;
|
|
return Result<std::optional<yams::topology::TopologyArtifactBatch>>{
|
|
std::optional<yams::topology::TopologyArtifactBatch>{batch}};
|
|
});
|
|
|
|
auto first = cache.get(batch.topologyEpoch);
|
|
REQUIRE(first.has_value());
|
|
REQUIRE(first.value().snapshot);
|
|
CHECK_FALSE(first.value().cacheHit);
|
|
CHECK((first.value().snapshot->clustersById.size() == batch.clusters.size()));
|
|
CHECK((first.value().snapshot->membershipsByDocumentHash.size() == batch.memberships.size()));
|
|
|
|
auto second = cache.get(batch.topologyEpoch);
|
|
REQUIRE(second.has_value());
|
|
REQUIRE(second.value().snapshot);
|
|
CHECK(second.value().cacheHit);
|
|
CHECK((loadCalls == 1));
|
|
}
|
|
|
|
TEST_CASE("Topology snapshot cache upgrades an exact index when ANN is later required",
|
|
"[search][topology][cache][ann][catch2]") {
|
|
auto batch = buildTwoClusterTopologyBatch();
|
|
int loadCalls = 0;
|
|
TopologyRoutingSnapshotCache cache([&]() {
|
|
++loadCalls;
|
|
return Result<std::optional<yams::topology::TopologyArtifactBatch>>{
|
|
std::optional<yams::topology::TopologyArtifactBatch>{batch}};
|
|
});
|
|
|
|
const auto exact = cache.get(batch.topologyEpoch, false);
|
|
REQUIRE(exact.has_value());
|
|
REQUIRE(exact.value().snapshot);
|
|
CHECK_FALSE(exact.value().snapshot->denseAnnBuildAttempted);
|
|
CHECK_FALSE(exact.value().snapshot->sparseRouteIndex.centroidAnnIndex);
|
|
|
|
const auto upgraded = cache.get(batch.topologyEpoch, true);
|
|
REQUIRE(upgraded.has_value());
|
|
REQUIRE(upgraded.value().snapshot);
|
|
CHECK(upgraded.value().cacheHit);
|
|
CHECK(upgraded.value().snapshot->denseAnnBuildAttempted);
|
|
CHECK(upgraded.value().snapshot->sparseRouteIndex.centroidAnnIndex);
|
|
CHECK(loadCalls == 1);
|
|
}
|
|
|
|
TEST_CASE("Topology construction fingerprint excludes publication and routing representatives",
|
|
"[search][topology][cache][identity][catch2]") {
|
|
auto baseline = buildTwoClusterTopologyBatch();
|
|
auto experimental = baseline;
|
|
experimental.snapshotId = "different-publication";
|
|
experimental.generatedAtUnixSeconds += 100;
|
|
experimental.topologyEpoch += 1;
|
|
experimental.clusters.front().routingRepresentatives.push_back(
|
|
yams::topology::ClusterRoutingRepresentative{.documentHash = "x1",
|
|
.embedding = {1.0F, 0.0F}});
|
|
|
|
const auto baselineIdentity = topologyRoutingConstructionFingerprint(baseline);
|
|
const auto experimentalIdentity = topologyRoutingConstructionFingerprint(experimental);
|
|
REQUIRE(baselineIdentity.size() == 16);
|
|
CHECK(experimentalIdentity == baselineIdentity);
|
|
const auto baselineRepresentations = topologyRoutingRepresentationFingerprint(baseline);
|
|
CHECK(topologyRoutingRepresentationFingerprint(experimental) != baselineRepresentations);
|
|
auto options = TopologyRoutingOptions{};
|
|
const auto policyIdentity = topologyRoutingPolicyFingerprint(baselineRepresentations, options);
|
|
options.collectGraphDiagnostics = true;
|
|
CHECK(topologyRoutingPolicyFingerprint(baselineRepresentations, options) == policyIdentity);
|
|
options.sparseDenseAlpha = 0.25F;
|
|
CHECK(topologyRoutingPolicyFingerprint(baselineRepresentations, options) != policyIdentity);
|
|
options = TopologyRoutingOptions{};
|
|
options.graphNeighborReciprocalOnly = false;
|
|
CHECK(topologyRoutingPolicyFingerprint(baselineRepresentations, options) != policyIdentity);
|
|
|
|
experimental.embeddingSpaceIdentity = "test-space-v2";
|
|
CHECK(topologyRoutingConstructionFingerprint(experimental) != baselineIdentity);
|
|
experimental.embeddingSpaceIdentity = baseline.embeddingSpaceIdentity;
|
|
|
|
experimental.protectedRelationIdentity = "rewired-relation";
|
|
CHECK(topologyRoutingConstructionFingerprint(experimental) != baselineIdentity);
|
|
experimental.protectedRelationIdentity = baseline.protectedRelationIdentity;
|
|
|
|
experimental.clusters.front().memberDocumentHashes.push_back("new-member");
|
|
CHECK(topologyRoutingConstructionFingerprint(experimental) != baselineIdentity);
|
|
}
|
|
|
|
TEST_CASE("Topology routing refuses theorem admission across coordinate spaces",
|
|
"[search][topology][identity][admission][catch2]") {
|
|
TopologySearchFixture fix;
|
|
seedTopologyDocuments(fix);
|
|
auto batch = buildTwoClusterTopologyBatch();
|
|
const auto constructionFingerprint = topologyRoutingConstructionFingerprint(batch);
|
|
|
|
TopologyRoutingSessionRequest request;
|
|
request.query = "omega";
|
|
request.seedDocumentHashes = {"y2"};
|
|
request.queryEmbedding = std::vector<float>{0.0F, 1.0F};
|
|
request.queryEmbeddingSpaceIdentity = "different-space-v1";
|
|
request.options.routingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
request.options.weakTier1Query = true;
|
|
request.options.maxClusters = 1;
|
|
request.options.maxDocs = 0;
|
|
request.options.collectRouteMembership = true;
|
|
request.options.routeRiskCalibration.constructionFingerprint = constructionFingerprint;
|
|
request.options.routeRiskCalibration.routingPolicyFingerprint =
|
|
topologyRoutingPolicyFingerprint(topologyRoutingRepresentationFingerprint(batch),
|
|
request.options);
|
|
request.options.routeRiskCalibration.datasetIdentity = "fixture-held-out-v1";
|
|
request.options.routeRiskCalibration.calibrationQueries = 50;
|
|
request.options.routeRiskCalibration.protectedCandidates = 100;
|
|
request.options.routeRiskCalibration.missedProtectedCandidates = 0;
|
|
request.options.routeRiskCalibration.minCalibrationQueries = 30;
|
|
request.options.routeRiskCalibration.maxMissesPerThousand = 10;
|
|
request.options.routeRiskCalibration.minBoundaryMargin = 0.0F;
|
|
request.options.routeRiskCalibration.minSeedHits = 1;
|
|
request.snapshotCache = std::make_shared<TopologyRoutingSnapshotCache>([batch]() mutable {
|
|
return Result<std::optional<yams::topology::TopologyArtifactBatch>>{
|
|
std::optional<yams::topology::TopologyArtifactBatch>{std::move(batch)}};
|
|
});
|
|
|
|
const auto mismatched = runTopologyRoutingSession(request, fix.repo, fix.kgStore);
|
|
REQUIRE(mismatched.certificate.hasUsefulRoute());
|
|
CHECK(mismatched.certificate.admission.coordinateSpaceAlignment ==
|
|
TopologyProofObligationStatus::Violated);
|
|
CHECK(mismatched.certificate.admission.denialReason() == "coordinate_space_mismatch");
|
|
CHECK(mismatched.certificate.action() == TopologyRouteAction::Augment);
|
|
|
|
request.queryEmbeddingSpaceIdentity = "test-space-v1";
|
|
const auto aligned = runTopologyRoutingSession(request, fix.repo, fix.kgStore);
|
|
CHECK(aligned.certificate.admission.coordinateSpaceAlignment ==
|
|
TopologyProofObligationStatus::Satisfied);
|
|
CHECK(aligned.certificate.admission.routeRisk == TopologyProofObligationStatus::Satisfied);
|
|
|
|
// Counts from an older routing policy cannot certify a changed representative budget.
|
|
request.options.representativeLimit = 1;
|
|
const auto changedPolicy = runTopologyRoutingSession(request, fix.repo, fix.kgStore);
|
|
CHECK(changedPolicy.certificate.admission.routeRisk ==
|
|
TopologyProofObligationStatus::Unavailable);
|
|
}
|
|
|
|
TEST_CASE("Topology snapshot cache rejects duplicate cluster identifiers",
|
|
"[search][topology][cache][validation][catch2]") {
|
|
auto batch = buildTwoClusterTopologyBatch();
|
|
REQUIRE((batch.clusters.size() >= 2));
|
|
batch.clusters[1].clusterId = batch.clusters[0].clusterId;
|
|
|
|
TopologyRoutingSnapshotCache cache([batch]() mutable {
|
|
return Result<std::optional<yams::topology::TopologyArtifactBatch>>{
|
|
std::optional<yams::topology::TopologyArtifactBatch>{std::move(batch)}};
|
|
});
|
|
|
|
const auto result = cache.get();
|
|
REQUIRE_FALSE(result.has_value());
|
|
CHECK((result.error().message == "invalid_artifact:duplicate_cluster_id"));
|
|
}
|
|
|
|
TEST_CASE("Topology snapshot cache admits a materialized boundary spill",
|
|
"[search][topology][cache][validation][overlap][catch2]") {
|
|
auto batch = buildTwoClusterTopologyBatch();
|
|
REQUIRE(batch.clusters.size() == 2);
|
|
const auto primaryId = batch.memberships.front().clusterId;
|
|
auto secondary = std::ranges::find_if(
|
|
batch.clusters, [&](const auto& cluster) { return cluster.clusterId != primaryId; });
|
|
REQUIRE(secondary != batch.clusters.end());
|
|
secondary->memberDocumentHashes.push_back(batch.memberships.front().documentHash);
|
|
std::ranges::sort(secondary->memberDocumentHashes);
|
|
secondary->memberCount = secondary->memberDocumentHashes.size();
|
|
batch.memberships.front().overlapClusterIds = {secondary->clusterId};
|
|
|
|
TopologyRoutingSnapshotCache cache([batch]() mutable {
|
|
return Result<std::optional<yams::topology::TopologyArtifactBatch>>{
|
|
std::optional<yams::topology::TopologyArtifactBatch>{std::move(batch)}};
|
|
});
|
|
|
|
const auto result = cache.get();
|
|
REQUIRE(result.has_value());
|
|
REQUIRE(result.value().snapshot);
|
|
CHECK(result.value()
|
|
.snapshot->sparseRouteIndex.clustersByDocumentHash
|
|
.at(batch.memberships.front().documentHash)
|
|
.size() == 2);
|
|
}
|
|
|
|
TEST_CASE("mergeTopologySeedHashes keeps Tier-1 first and caps vector seeds",
|
|
"[unit][search][topology][graph_neighbors]") {
|
|
const auto merged =
|
|
mergeTopologySeedHashes({"t1", "t2", "t1"}, {"v1", "t2", "v2", "v3"}, /*maxVectorSeeds=*/2);
|
|
REQUIRE((merged.size() == 4));
|
|
CHECK((merged[0] == "t1"));
|
|
CHECK((merged[1] == "t2"));
|
|
CHECK((merged[2] == "v1"));
|
|
CHECK((merged[3] == "v2"));
|
|
|
|
const auto none = mergeTopologySeedHashes({"t1", "t2"}, {"v1", "v2"}, /*maxVectorSeeds=*/0);
|
|
REQUIRE((none.size() == 2));
|
|
CHECK((none[0] == "t1"));
|
|
CHECK((none[1] == "t2"));
|
|
|
|
const auto emptyVec = mergeTopologySeedHashes({"t1"}, {}, /*maxVectorSeeds=*/16);
|
|
REQUIRE((emptyVec.size() == 1));
|
|
CHECK((emptyVec[0] == "t1"));
|
|
}
|
|
|
|
TEST_CASE("rankTopologySeedEvidence keeps ranked lexical evidence and caps fanout",
|
|
"[unit][search][topology][weighted-seeds]") {
|
|
std::vector<ComponentResult> components{
|
|
ComponentResult{.documentHash = "rank-zero",
|
|
.score = 0.80F,
|
|
.source = ComponentResult::Source::Text,
|
|
.rank = 0},
|
|
ComponentResult{.documentHash = "late-high",
|
|
.score = 0.95F,
|
|
.source = ComponentResult::Source::Text,
|
|
.rank = 10},
|
|
ComponentResult{.documentHash = "early",
|
|
.score = 0.80F,
|
|
.source = ComponentResult::Source::Text,
|
|
.rank = 1},
|
|
ComponentResult{.documentHash = "late-high",
|
|
.score = 0.90F,
|
|
.source = ComponentResult::Source::SimeonText,
|
|
.rank = 2},
|
|
ComponentResult{.documentHash = "vector-only",
|
|
.score = 1.0F,
|
|
.source = ComponentResult::Source::Vector,
|
|
.rank = 1},
|
|
};
|
|
|
|
const auto evidence = rankTopologySeedEvidence(components, 3);
|
|
REQUIRE((evidence.size() == 3));
|
|
CHECK((evidence[0].documentHash == "rank-zero"));
|
|
CHECK((evidence[1].documentHash == "early"));
|
|
CHECK((evidence[2].documentHash == "late-high"));
|
|
CHECK((evidence[0].weight > evidence[1].weight));
|
|
}
|
|
|
|
TEST_CASE("rankTopologyVectorSeedEvidence preserves vector score and rank",
|
|
"[unit][search][topology][weighted-seeds]") {
|
|
std::vector<ComponentResult> components{
|
|
ComponentResult{.documentHash = "near",
|
|
.score = 0.90F,
|
|
.source = ComponentResult::Source::Vector,
|
|
.rank = 0},
|
|
ComponentResult{.documentHash = "far",
|
|
.score = 0.95F,
|
|
.source = ComponentResult::Source::Vector,
|
|
.rank = 10},
|
|
ComponentResult{.documentHash = "near",
|
|
.score = 0.80F,
|
|
.source = ComponentResult::Source::Vector,
|
|
.rank = 2},
|
|
};
|
|
|
|
const auto evidence = rankTopologyVectorSeedEvidence(components, 2);
|
|
REQUIRE(evidence.size() == 2);
|
|
CHECK(evidence[0].documentHash == "near");
|
|
CHECK(evidence[1].documentHash == "far");
|
|
CHECK(evidence[0].weight > evidence[1].weight);
|
|
}
|
|
|
|
TEST_CASE("selectTopologyRoutesForNarrowing adapts probes and abstains at an unsafe boundary",
|
|
"[unit][search][topology][adaptive-probe]") {
|
|
std::vector<yams::topology::ClusterRoute> routes{
|
|
yams::topology::ClusterRoute{.clusterId = "a", .routeScore = 1.00},
|
|
yams::topology::ClusterRoute{.clusterId = "b", .routeScore = 0.97},
|
|
yams::topology::ClusterRoute{.clusterId = "c", .routeScore = 0.80},
|
|
};
|
|
|
|
const auto safe = selectTopologyRoutesForNarrowing(routes, /*minClusters=*/1, /*maxClusters=*/3,
|
|
/*adaptiveScoreGap=*/0.05F,
|
|
/*minBoundaryMargin=*/0.10F);
|
|
REQUIRE((safe.routes.size() == 2));
|
|
CHECK((safe.routes[0].clusterId == "a"));
|
|
CHECK((safe.routes[1].clusterId == "b"));
|
|
CHECK((safe.boundaryScoreMargin == 0.17F));
|
|
CHECK_FALSE(safe.abstained);
|
|
|
|
const auto unsafe = selectTopologyRoutesForNarrowing(
|
|
routes, /*minClusters=*/1, /*maxClusters=*/2, /*adaptiveScoreGap=*/0.05F,
|
|
/*minBoundaryMargin=*/0.20F);
|
|
REQUIRE((unsafe.routes.size() == 2));
|
|
CHECK(unsafe.abstained);
|
|
}
|
|
|
|
TEST_CASE("legacy topology false preserves the hybrid product default",
|
|
"[unit][search][topology][config]") {
|
|
SearchEngineConfig config;
|
|
REQUIRE(config.topologyRoutingMode == SearchEngineConfig::TopologyRoutingMode::HybridAssist);
|
|
|
|
CHECK(resolveTopologyRoutingMode(config, false) ==
|
|
SearchEngineConfig::TopologyRoutingMode::HybridAssist);
|
|
CHECK(resolveTopologyRoutingMode(config, true) ==
|
|
SearchEngineConfig::TopologyRoutingMode::WeakQueryOnly);
|
|
|
|
config.topologyRoutingMode = SearchEngineConfig::TopologyRoutingMode::Disabled;
|
|
CHECK(resolveTopologyRoutingMode(config) == SearchEngineConfig::TopologyRoutingMode::Disabled);
|
|
}
|
|
|
|
TEST_CASE("fillTopologySkipReason preserves session reason and fills product defaults",
|
|
"[unit][search][topology]") {
|
|
using Mode = SearchEngineConfig::TopologyRoutingMode;
|
|
TopologyRoutingOptions options;
|
|
TopologyRoutingSessionResult session;
|
|
|
|
options.routingMode = Mode::HybridAssist;
|
|
std::string reason = "graph_medoid_neighbors";
|
|
fillTopologySkipReason(reason, options, /*hasStores=*/true, session);
|
|
CHECK((reason == "graph_medoid_neighbors"));
|
|
|
|
options.routingMode = Mode::Disabled;
|
|
reason.clear();
|
|
fillTopologySkipReason(reason, options, /*hasStores=*/true, session);
|
|
CHECK((reason == "disabled"));
|
|
|
|
options.routingMode = Mode::HybridAssist;
|
|
session.loadSucceeded = true;
|
|
session.routedClusters = 2;
|
|
reason.clear();
|
|
fillTopologySkipReason(reason, options, /*hasStores=*/true, session);
|
|
CHECK((reason == "no_added_candidates"));
|
|
}
|
|
|
|
TEST_CASE("rankGraphNeighborCandidates ranks multi-seed hits and filters score/reciprocal",
|
|
"[unit][search][topology][graph_neighbors]") {
|
|
std::unordered_map<std::string, std::vector<std::tuple<std::string, float, bool>>> adj;
|
|
adj["s1"] = {
|
|
{"n_shared", 0.9F, true},
|
|
{"n_weak", 0.1F, true},
|
|
{"n_oneway", 0.95F, false},
|
|
};
|
|
adj["s2"] = {
|
|
{"n_shared", 0.85F, true},
|
|
// single-seed only — loses to multi-seed n_shared even with higher edge score
|
|
{"n_strong", 0.99F, true},
|
|
};
|
|
|
|
auto ranked = rankGraphNeighborCandidates(adj, {"s1", "s2"}, /*maxDocs=*/10,
|
|
/*minScore=*/0.25F, /*reciprocalOnly=*/true);
|
|
REQUIRE_FALSE(ranked.empty());
|
|
// Multi-seed hits beat single-seed even when the single edge score is higher.
|
|
CHECK((ranked.front() == "n_shared"));
|
|
// Weak edge below minScore and one-way reciprocal-only drop out.
|
|
CHECK((std::find(ranked.begin(), ranked.end(), "n_weak") == ranked.end()));
|
|
CHECK((std::find(ranked.begin(), ranked.end(), "n_oneway") == ranked.end()));
|
|
CHECK((std::find(ranked.begin(), ranked.end(), "n_strong") != ranked.end()));
|
|
|
|
auto limited = rankGraphNeighborCandidates(adj, {"s1", "s2"}, /*maxDocs=*/1, 0.25F, true);
|
|
REQUIRE((limited.size() == 1));
|
|
CHECK((limited.front() == "n_shared"));
|
|
|
|
// Reverse-only / one-way edges still rank when reciprocalOnly=false.
|
|
std::unordered_map<std::string, std::vector<std::tuple<std::string, float, bool>>> oneWay;
|
|
oneWay["s1"] = {{"n_oneway", 0.9F, false}};
|
|
auto oneWayRanked =
|
|
rankGraphNeighborCandidates(oneWay, {"s1"}, 5, 0.25F, /*reciprocalOnly=*/false);
|
|
REQUIRE((oneWayRanked.size() == 1));
|
|
CHECK((oneWayRanked.front() == "n_oneway"));
|
|
auto oneWayStrict =
|
|
rankGraphNeighborCandidates(oneWay, {"s1"}, 5, 0.25F, /*reciprocalOnly=*/true);
|
|
CHECK(oneWayStrict.empty());
|
|
}
|
|
|
|
TEST_CASE("rankGraphNeighborCandidates can weight support by query seed evidence",
|
|
"[unit][search][topology][graph_neighbors][weighted-seeds]") {
|
|
std::unordered_map<std::string, std::vector<std::tuple<std::string, float, bool>>> adjacency;
|
|
adjacency["low-1"] = {{"popular", 0.9F, true}};
|
|
adjacency["low-2"] = {{"popular", 0.9F, true}};
|
|
adjacency["high"] = {{"focused", 0.9F, true}};
|
|
const std::vector<std::string> hashes{"low-1", "low-2", "high"};
|
|
const std::vector<yams::topology::WeightedDocumentSeed> evidence{
|
|
{.documentHash = "low-1", .weight = 0.1F},
|
|
{.documentHash = "low-2", .weight = 0.1F},
|
|
{.documentHash = "high", .weight = 1.0F},
|
|
};
|
|
|
|
const auto unweighted = rankGraphNeighborCandidates(adjacency, hashes, /*maxDocs=*/2,
|
|
/*minScore=*/0.0F, /*reciprocalOnly=*/true);
|
|
REQUIRE(unweighted.size() == 2);
|
|
CHECK(unweighted.front() == "popular");
|
|
|
|
const auto weighted =
|
|
rankGraphNeighborCandidates(adjacency, hashes, /*maxDocs=*/2, /*minScore=*/0.0F,
|
|
/*reciprocalOnly=*/true, evidence);
|
|
REQUIRE(weighted.size() == 2);
|
|
CHECK(weighted.front() == "focused");
|
|
}
|
|
|
|
TEST_CASE("SearchEngine topology merge keeps graph-vector candidates behind baseline anchors",
|
|
"[search][topology][graph-vector][characterization][catch2]") {
|
|
using yams::search::detail::AuxiliaryVectorCandidateBatch;
|
|
|
|
SearchEngineConfig config;
|
|
config.vectorMaxResults = 2;
|
|
config.graphExpansionVectorPenalty = 0.5F;
|
|
config.graphVectorRequireCorroboration = true;
|
|
config.graphVectorRequireTextAnchoring = true;
|
|
config.graphVectorRequireBaselineTextAnchoring = true;
|
|
|
|
std::vector<ComponentResult> components{
|
|
{.documentHash = "baseline-anchor", .score = 0.9F, .source = ComponentResult::Source::Text},
|
|
{.documentHash = "graph-anchor",
|
|
.score = 0.8F,
|
|
.source = ComponentResult::Source::GraphText},
|
|
};
|
|
std::vector<AuxiliaryVectorCandidateBatch> graphTerms{
|
|
{.query = "related",
|
|
.weight = 1.0F,
|
|
.candidates = {{.documentHash = "baseline-anchor", .score = 1.0F},
|
|
{.documentHash = "graph-anchor", .score = 1.0F},
|
|
{.documentHash = "unanchored", .score = 1.0F}}},
|
|
};
|
|
|
|
const auto merged = yams::search::detail::mergeAuxiliaryVectorCandidates(
|
|
std::move(components), {}, std::move(graphTerms), config);
|
|
|
|
CHECK(merged.stats.graphVectorRawHitCount == 3U);
|
|
CHECK(merged.stats.graphVectorAddedNewCount == 1U);
|
|
CHECK(merged.stats.graphVectorBlockedUncorroboratedCount == 1U);
|
|
CHECK(merged.stats.graphVectorBlockedMissingBaselineTextAnchorCount == 1U);
|
|
REQUIRE(merged.components.size() == 3U);
|
|
CHECK(merged.components.back().documentHash == "baseline-anchor");
|
|
CHECK(merged.components.back().source == ComponentResult::Source::GraphVector);
|
|
CHECK(merged.components.back().score == Catch::Approx(0.5F));
|
|
}
|
|
|
|
TEST_CASE("Graph-neighbor trace separates stored relation from the selected cap",
|
|
"[unit][search][topology][graph_neighbors][trace]") {
|
|
TopologySearchFixture fix;
|
|
fix.addDocument("seed", "seed", {1.0F, 0.0F});
|
|
fix.addDocument("near", "near", {0.9F, 0.1F});
|
|
fix.addDocument("far", "far", {0.8F, 0.2F});
|
|
|
|
const auto nodeIds = fix.kgStore->upsertNodes({
|
|
KGNode{.nodeKey = "doc:seed", .type = "document"},
|
|
KGNode{.nodeKey = "doc:near", .type = "document"},
|
|
KGNode{.nodeKey = "doc:far", .type = "document"},
|
|
});
|
|
REQUIRE(nodeIds.has_value());
|
|
REQUIRE(nodeIds.value().size() == 3);
|
|
REQUIRE(fix.kgStore
|
|
->addEdge(KGEdge{.srcNodeId = nodeIds.value()[0],
|
|
.dstNodeId = nodeIds.value()[1],
|
|
.relation = "semantic_neighbor",
|
|
.weight = 0.9F})
|
|
.has_value());
|
|
REQUIRE(fix.kgStore
|
|
->addEdge(KGEdge{.srcNodeId = nodeIds.value()[0],
|
|
.dstNodeId = nodeIds.value()[2],
|
|
.relation = "semantic_neighbor",
|
|
.weight = 0.8F})
|
|
.has_value());
|
|
|
|
TopologyRoutingSessionRequest request;
|
|
request.seedDocumentHashes = {"seed"};
|
|
request.options.routingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
request.options.expansionSource = SearchEngineConfig::TopologyExpansionSource::GraphNeighbors;
|
|
request.options.maxDocs = 1;
|
|
request.options.collectRouteMembership = true;
|
|
request.options.collectGraphDiagnostics = true;
|
|
request.options.graphNeighborMinScore = 0.0F;
|
|
request.options.graphNeighborReciprocalOnly = false;
|
|
|
|
const auto result = runTopologyRoutingSession(request, fix.repo, fix.kgStore);
|
|
|
|
REQUIRE(result.graphNeighborTrace.collected);
|
|
CHECK(result.graphNeighborTrace.edgeFetchLimit == 64);
|
|
CHECK(result.graphNeighborTrace.fetchTruncatedSeedCount == 0);
|
|
CHECK(result.graphNeighborTrace.seedDocumentIds == std::vector<std::string>{"seed"});
|
|
CHECK(result.graphNeighborTrace.relationCandidateCount == 2);
|
|
CHECK(result.graphNeighborTrace.relationCandidateDocumentIds ==
|
|
std::vector<std::string>{"near", "far"});
|
|
CHECK(result.graphNeighborTrace.fetchedCandidateCount == 2);
|
|
CHECK(result.graphNeighborTrace.fetchedCandidateDocumentIds ==
|
|
std::vector<std::string>{"near", "far"});
|
|
CHECK(result.graphNeighborTrace.eligibleCandidateCount == 2);
|
|
CHECK(result.graphNeighborTrace.eligibleCandidateDocumentIds ==
|
|
std::vector<std::string>{"near", "far"});
|
|
REQUIRE(result.routedCandidateDocIds.size() == 1);
|
|
CHECK(result.routedCandidateDocIds.front() == "near");
|
|
}
|
|
|
|
TEST_CASE("Topology cluster hydration failure discards partial admission",
|
|
"[unit][search][topology][hydration]") {
|
|
TopologySearchFixture fix;
|
|
auto counting = std::make_shared<HashLookupCountingRepository>(*fix.pool);
|
|
fix.repo = counting;
|
|
seedTopologyDocuments(fix);
|
|
const auto batch = buildTwoClusterTopologyBatch();
|
|
TopologyRoutingSessionRequest request;
|
|
request.seedDocumentHashes = {"x1", "y1"};
|
|
request.options.routingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
request.options.expansionSource = SearchEngineConfig::TopologyExpansionSource::Clusters;
|
|
request.options.maxClusters = 2;
|
|
request.options.maxDocs = 8;
|
|
request.options.minRouteScore = 0.0F;
|
|
request.snapshotCache = std::make_shared<TopologyRoutingSnapshotCache>(
|
|
[batch] { return Result<std::optional<yams::topology::TopologyArtifactBatch>>{batch}; });
|
|
counting->batchLookups = 0;
|
|
SECTION("all cluster lookups succeed") {
|
|
const auto result = runTopologyRoutingSession(request, fix.repo, fix.kgStore);
|
|
CHECK(result.applied);
|
|
// Non-membership expansion materializes one medoid per selected cluster.
|
|
CHECK(result.addedCandidateHashes.size() == 2);
|
|
CHECK(counting->batchLookups.load() == 2);
|
|
}
|
|
SECTION("second cluster lookup fails after the first was admitted") {
|
|
counting->failBatchAt = 2;
|
|
const auto result = runTopologyRoutingSession(request, fix.repo, fix.kgStore);
|
|
CHECK(counting->batchLookups.load() == 2);
|
|
CHECK_FALSE(result.applied);
|
|
CHECK(result.staleCandidates == 0);
|
|
CHECK(result.skipReason.starts_with("metadata_lookup_failed"));
|
|
CHECK(result.certificate.allowedDocumentHashes.empty());
|
|
CHECK(result.routedCandidateHashes.empty());
|
|
CHECK(result.addedCandidateHashes.empty());
|
|
CHECK(result.addedCandidates == 0);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Topology routing hydrates ranked candidates and trace stages in batches",
|
|
"[unit][search][topology][graph_neighbors][hydration]") {
|
|
TopologySearchFixture fix;
|
|
auto counting = std::make_shared<HashLookupCountingRepository>(*fix.pool);
|
|
fix.repo = counting;
|
|
fix.addDocument("seed", "seed", {1.0F, 0.0F});
|
|
std::vector<KGNode> nodes{KGNode{.nodeKey = "doc:seed", .type = "document"}};
|
|
for (int i = 0; i < 8; ++i) {
|
|
const auto hash = "near" + std::to_string(i);
|
|
fix.addDocument(hash, hash, {0.9F, 0.1F});
|
|
nodes.push_back(KGNode{.nodeKey = "doc:" + hash, .type = "document"});
|
|
}
|
|
const auto nodeIds = fix.kgStore->upsertNodes(nodes);
|
|
REQUIRE(nodeIds.has_value());
|
|
REQUIRE(nodeIds.value().size() == 9);
|
|
for (std::size_t i = 1; i < nodeIds.value().size(); ++i) {
|
|
REQUIRE(fix.kgStore
|
|
->addEdge(KGEdge{.srcNodeId = nodeIds.value()[0],
|
|
.dstNodeId = nodeIds.value()[i],
|
|
.relation = "semantic_neighbor",
|
|
.weight = 0.9F})
|
|
.has_value());
|
|
}
|
|
TopologyRoutingSessionRequest request;
|
|
request.seedDocumentHashes = {"seed"};
|
|
request.options.routingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist;
|
|
request.options.expansionSource = SearchEngineConfig::TopologyExpansionSource::GraphNeighbors;
|
|
request.options.maxDocs = 8;
|
|
request.options.collectRouteMembership = true;
|
|
request.options.collectGraphDiagnostics = true;
|
|
request.options.graphNeighborMinScore = 0.0F;
|
|
request.options.graphNeighborReciprocalOnly = false;
|
|
|
|
counting->singleLookups = 0;
|
|
counting->batchLookups = 0;
|
|
SECTION("successful hydration remains batched") {
|
|
const auto result = runTopologyRoutingSession(request, fix.repo, fix.kgStore);
|
|
REQUIRE(result.graphNeighborTrace.collected);
|
|
CHECK(result.graphNeighborTrace.relationCandidateCount == 8);
|
|
REQUIRE(result.routedCandidateDocIds.size() == 8);
|
|
CHECK(counting->batchLookups.load() >= 1);
|
|
CHECK(counting->singleLookups.load() == 0);
|
|
}
|
|
SECTION("a diagnostic batch failure retains per-document fallback") {
|
|
counting->failBatchAt = 1;
|
|
const auto result = runTopologyRoutingSession(request, fix.repo, fix.kgStore);
|
|
CHECK(result.applied);
|
|
CHECK(result.staleCandidates == 0);
|
|
CHECK(result.graphNeighborTrace.eligibleUnresolvedCount == 0);
|
|
CHECK(result.graphNeighborTrace.eligibleCandidateDocumentIds.size() == 8);
|
|
CHECK(counting->singleLookups.load() > 0);
|
|
}
|
|
SECTION("metadata failure is not evidence of stale documents") {
|
|
counting->failBatchLookups = true;
|
|
const auto result = runTopologyRoutingSession(request, fix.repo, fix.kgStore);
|
|
CHECK_FALSE(result.applied);
|
|
CHECK(result.staleCandidates == 0);
|
|
CHECK(result.skipReason.starts_with("metadata_lookup_failed"));
|
|
CHECK(result.certificate.allowedDocumentHashes.empty());
|
|
CHECK(result.addedCandidateHashes.empty());
|
|
}
|
|
}
|