* 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>
214 lines
9.5 KiB
C++
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");
|
|
}
|