yams/tests/unit/search/search_vector_pipeline_catch2_test.cpp
Trevon 3f37e8f564
Sync experimental into main: Search retrieval improvements (#60)
* fix(graph): handle qualified C++ symbols

Signed-off-by: trvon <me@trevon.dev>

* refactor(vector)!: remove dead transition APIs

Split Simeon vector and retrieval linkage so consumers inherit only the runtime surface they use.

BREAKING CHANGE: remove unused async/model/config helpers and the undefined vector-backend factory from public headers.

Signed-off-by: trvon <me@trevon.dev>

* fix(vector): harden PQ snapshot lifecycle

Validate and atomically install persisted generations, revalidate after races, and fall back to exact search when a snapshot is stale. Add the exact-scan reference lane and distinguish observed work counters from unavailable backend metrics.

Signed-off-by: trvon <me@trevon.dev>

* feat(topology): expose route coordinates

Persist chart distortion and local-dimension evidence, retain pre-scalarization route axes, and emit detailed trace rows without treating unavailable vector work as zero.

Signed-off-by: trvon <me@trevon.dev>

* feat(xplan): add vector decision gates

Track binary and corpus identity, preserve missing-work semantics, and add repeated PQ routing, crossover, and rerank plans with quality and cost summaries.

Signed-off-by: trvon <me@trevon.dev>

* feat(topology): formalize coordinate routing

Separate chart geometry from route-risk admission and prove candidate-stage recall and strict-focus boundaries. Record the measured atlas limitations and literature basis for the next construction experiment.

Signed-off-by: trvon <me@trevon.dev>

* fix(search): serialize SymSpell readers

SQLite-backed lookups reuse mutable prepared statements, so shared-reader locking is unsafe. Advance the owned SymSpell revision for batched delete insertion and cover concurrent root lookups under TSAN.

Signed-off-by: trvon <me@trevon.dev>

* fix(vector): make mutations atomic

Preserve stored vectors on replacement and commit failures, reject cross-thread writes during explicit transactions, and restore exact reranking for quantized-primary PQ rows.

* fix(search)!: surface SymSpell write errors

Propagate persistent dictionary failures through metadata ingestion, roll back failed batches, and make clear remove stored terms.

BREAKING CHANGE: SymSpellSearch mutations now return Result values.

* feat(topology): formalize candidate expansion

* feat(vector): add coordinate-aware embeddings

* feat(topology): preserve protected relation cover

* feat(search): add graph candidate rescue

* feat(daemon): expose retrieval hydration telemetry

* feat(xplan): add rescue and hydration gates

* refactor(content): dedupe media handlers

Reuse shared graph presenter helpers and centralize repeated audio/video/archive buffer processing, validation, lifecycle, and stats helper logic.

* feat(lifecycle): prune stale corpus data

Keep cleanup preview-first while retention stays opt-in and long prune work no longer holds IPC responses open.

* feat(search): add topology candidate rescue

Preserve high-value routed candidates across vector backends while keeping exact rerank diagnostics explicit.

* feat(search): expose evidence paths

* docs: define mobile host contract

* formal: model bounded candidate rescue

* fix(prune): inspect extensionless signatures

* fix(prune): tolerate stale extensions

---------

Signed-off-by: trvon <me@trevon.dev>
2026-07-24 19:00:22 -04:00

214 lines
9.5 KiB
C++

#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "src/search/search_vector_pipeline_internal.h"
#include "tests/common/test_helpers_catch2.h"
#include <yams/vector/vector_database.h>
using yams::search::SearchEngineConfig;
TEST_CASE("Vector pipeline over-fetches chunks for aggregating unique docs",
"[search][vector][catch2]") {
SearchEngineConfig cfg;
cfg.chunkAggregation = SearchEngineConfig::ChunkAggregation::WEIGHTED_TOP_K_AVG;
cfg.chunkAggregationTopK = 3;
CHECK(yams::search::detail::testingVectorRawCandidateLimit(cfg, 10, false) == 30U);
CHECK(yams::search::detail::testingVectorRawCandidateLimit(cfg, 10, true) == 30U);
cfg.chunkAggregation = SearchEngineConfig::ChunkAggregation::MAX;
CHECK(yams::search::detail::testingVectorRawCandidateLimit(cfg, 10, false) == 10U);
}
TEST_CASE("Vector pipeline propagates exact routed work diagnostics",
"[search][vector][topology][catch2]") {
yams::test::ScopedEnvVar enableVectors("YAMS_DISABLE_VECTORS", std::string("0"));
yams::test::ScopedEnvVar enableSqliteVecInit("YAMS_SQLITE_VEC_SKIP_INIT", std::string("0"));
yams::vector::VectorDatabaseConfig dbConfig;
dbConfig.database_path = ":memory:";
dbConfig.embedding_dim = 2;
dbConfig.use_in_memory = true;
auto vectorDb = std::make_shared<yams::vector::VectorDatabase>(dbConfig);
REQUIRE(vectorDb->initialize());
for (const auto& [hash, embedding] : std::vector<std::pair<std::string, std::vector<float>>>{
{"a", {1.0F, 0.0F}}, {"b", {0.8F, 0.2F}}, {"c", {0.0F, 1.0F}}}) {
yams::vector::VectorRecord record;
record.chunk_id = "chunk-" + hash;
record.document_hash = hash;
record.embedding = embedding;
record.level = yams::vector::EmbeddingLevel::DOCUMENT;
REQUIRE(vectorDb->insertVector(record));
}
SearchEngineConfig cfg;
cfg.similarityThreshold = -1.0F;
cfg.chunkAggregation = SearchEngineConfig::ChunkAggregation::MAX;
yams::vector::VectorSearchDiagnostics diagnostics;
const std::unordered_set<std::string> routed{"a", "b"};
const auto result = yams::search::detail::queryVectorIndexPipeline(
nullptr, vectorDb, {1.0F, 0.0F}, cfg, 2, routed, yams::vector::CandidateFilterMode::Exact,
&diagnostics);
REQUIRE(result.has_value());
CHECK(result.value().size() == 2U);
CHECK(diagnostics.usedExactScan);
CHECK_FALSE(diagnostics.usedAnn);
CHECK(diagnostics.rowsVisited == 2U);
CHECK(diagnostics.exactDistanceEvaluations == 2U);
}
TEST_CASE("Vector pipeline scores every admitted document before exact-control truncation",
"[search][vector][topology][candidate-rescue][catch2]") {
yams::test::ScopedEnvVar enableVectors("YAMS_DISABLE_VECTORS", std::string("0"));
yams::test::ScopedEnvVar enableSqliteVecInit("YAMS_SQLITE_VEC_SKIP_INIT", std::string("0"));
yams::vector::VectorDatabaseConfig dbConfig;
dbConfig.database_path = ":memory:";
dbConfig.embedding_dim = 2;
dbConfig.use_in_memory = true;
auto vectorDb = std::make_shared<yams::vector::VectorDatabase>(dbConfig);
REQUIRE(vectorDb->initialize());
for (const auto& [chunkId, hash, embedding] :
std::vector<std::tuple<std::string, std::string, std::vector<float>>>{
{"crowding-1", "crowding", {1.0F, 0.0F}},
{"crowding-2", "crowding", {0.9F, 0.1F}},
{"rescued-1", "rescued", {0.6F, 0.8F}}}) {
yams::vector::VectorRecord record;
record.chunk_id = chunkId;
record.document_hash = hash;
record.embedding = embedding;
record.level = yams::vector::EmbeddingLevel::CHUNK;
REQUIRE(vectorDb->insertVector(record));
}
SearchEngineConfig cfg;
cfg.similarityThreshold = 0.7F;
cfg.chunkAggregation = SearchEngineConfig::ChunkAggregation::MAX;
const std::unordered_set<std::string> routed{"crowding", "rescued"};
const auto bounded = yams::search::detail::queryVectorIndexPipeline(
nullptr, vectorDb, {1.0F, 0.0F}, cfg, 2, routed, yams::vector::CandidateFilterMode::Exact);
REQUIRE(bounded.has_value());
REQUIRE(bounded.value().size() == 1U);
CHECK(bounded.value()[0].documentHash == "crowding");
yams::vector::VectorSearchDiagnostics diagnostics;
const auto documentComplete = yams::search::detail::queryVectorIndexPipeline(
nullptr, vectorDb, {1.0F, 0.0F}, cfg, 2, routed,
yams::vector::CandidateFilterMode::ExactDocumentComplete, &diagnostics);
REQUIRE(documentComplete.has_value());
REQUIRE(documentComplete.value().size() == 2U);
CHECK(documentComplete.value()[0].documentHash == "crowding");
CHECK(documentComplete.value()[1].documentHash == "rescued");
CHECK(documentComplete.value()[1].score == Catch::Approx(0.6F));
CHECK(diagnostics.rowsVisited == 3U);
CHECK(diagnostics.exactDistanceEvaluations == 3U);
CHECK(diagnostics.returnedRows == 3U);
}
TEST_CASE("Vector pipeline filters global hits by routed membership with zero-overlap fallback",
"[search][vector][topology][narrowing][catch2]") {
using yams::search::ComponentResult;
std::vector<ComponentResult> global{{.documentHash = "distractor", .score = 0.95F, .rank = 0},
{.documentHash = "routed-a", .score = 0.90F, .rank = 1},
{.documentHash = "routed-b", .score = 0.80F, .rank = 2}};
auto filtered = yams::search::detail::filterVectorResultsByAllowedDocuments(
global, std::unordered_set<std::string>{"routed-a", "routed-b"});
REQUIRE(filtered.applied);
CHECK_FALSE(filtered.fellBackToGlobal);
CHECK(filtered.matched == 2U);
CHECK(filtered.removed == 1U);
REQUIRE(filtered.results.size() == 2U);
CHECK(filtered.results[0].documentHash == "routed-a");
CHECK(filtered.results[0].rank == 0U);
CHECK(filtered.results[0].debugInfo.at("topology_route_filter") == "1");
CHECK(filtered.results[1].documentHash == "routed-b");
CHECK(filtered.results[1].rank == 1U);
auto fallback = yams::search::detail::filterVectorResultsByAllowedDocuments(
std::move(global), std::unordered_set<std::string>{"route-miss"});
CHECK_FALSE(fallback.applied);
REQUIRE(fallback.fellBackToGlobal);
CHECK(fallback.matched == 0U);
CHECK(fallback.removed == 0U);
REQUIRE(fallback.results.size() == 3U);
CHECK(fallback.results[0].documentHash == "distractor");
}
TEST_CASE("Vector pipeline candidate rescue preserves baseline and appends novel routed hits",
"[search][vector][candidate-rescue][catch2]") {
using yams::search::ComponentResult;
std::vector<ComponentResult> baseline{
{.documentHash = "baseline-a", .score = 0.90F, .rank = 0},
{.documentHash = "shared", .score = 0.70F, .rank = 1},
};
std::vector<ComponentResult> expansion{
{.documentHash = "rescued-hash",
.filePath = "/corpus/rescued-doc.txt",
.score = 0.95F,
.rank = 0},
{.documentHash = "shared", .score = 0.70F, .rank = 1},
};
auto merged = yams::search::detail::mergeVectorCandidateRescues(std::move(baseline),
std::move(expansion));
CHECK(merged.added == 1U);
CHECK(merged.novelDocuments == 1U);
CHECK(merged.evidenceRescues == 0U);
CHECK(merged.duplicates == 1U);
CHECK(merged.novelDocumentHashes == std::vector<std::string>{"rescued-hash"});
CHECK(merged.novelDocumentIds == std::vector<std::string>{"rescued-doc"});
CHECK(merged.evidenceRescueDocumentHashes.empty());
CHECK(merged.evidenceRescueDocumentIds.empty());
REQUIRE(merged.addedDocumentHashes.size() == 1U);
CHECK(merged.addedDocumentHashes[0] == "rescued-hash");
REQUIRE(merged.addedDocumentIds.size() == 1U);
CHECK(merged.addedDocumentIds[0] == "rescued-doc");
REQUIRE(merged.results.size() == 3U);
CHECK(merged.results[0].documentHash == "rescued-hash");
CHECK(merged.results[0].rank == 0U);
CHECK(merged.results[0].debugInfo.at("candidate_rescue") == "1");
CHECK(merged.results[1].documentHash == "baseline-a");
CHECK(merged.results[1].rank == 1U);
CHECK(merged.results[2].documentHash == "shared");
CHECK(merged.results[2].rank == 2U);
}
TEST_CASE("Vector pipeline candidate rescue enriches an existing lexical candidate",
"[search][vector][candidate-rescue][evidence][catch2]") {
using yams::search::ComponentResult;
std::vector<ComponentResult> baseline{
{.documentHash = "global-vector", .score = 0.90F, .rank = 0},
};
std::vector<ComponentResult> expansion{
{.documentHash = "buried-lexical",
.filePath = "/corpus/buried-lexical.txt",
.score = 0.80F,
.rank = 0},
};
auto merged = yams::search::detail::mergeVectorCandidateRescues(
std::move(baseline), std::move(expansion),
std::unordered_set<std::string>{"buried-lexical"});
CHECK(merged.added == 1U);
CHECK(merged.novelDocuments == 0U);
CHECK(merged.evidenceRescues == 1U);
CHECK(merged.duplicates == 0U);
CHECK(merged.novelDocumentHashes.empty());
CHECK(merged.novelDocumentIds.empty());
CHECK(merged.evidenceRescueDocumentHashes == std::vector<std::string>{"buried-lexical"});
CHECK(merged.evidenceRescueDocumentIds == std::vector<std::string>{"buried-lexical"});
REQUIRE(merged.results.size() == 2U);
CHECK(merged.results[1].documentHash == "buried-lexical");
CHECK(merged.results[1].debugInfo.at("candidate_rescue") == "1");
CHECK(merged.results[1].debugInfo.at("candidate_rescue_kind") == "evidence");
}