yams/tests/integration/vector/hnsw_retrieval_diagnostic.cpp
Trevon 0dd3985db4
refactor: simplify runtime paths and harden retrieval (#61)
* refactor(tuning): prune dormant runtime knobs

Remove unused tuning controls and adaptive search budget state. Extract testable ONNX slot reconciliation and search telemetry hydration seams, and correct the Apple Silicon sysctl value width.

* refactor(vector): retire dormant backends

Remove unused FAISS, model-infrastructure, VectorIndexManager, and TurboQuant paths while keeping Simeon PQ and float-vector storage.

Migrate unused V2.2 quantized schemas transactionally to V2.3. Databases containing non-default quantized formats or packed-code blobs fail closed with recovery guidance and no mutation.

* refactor(plugins): remove legacy SDK tooling

Remove the Ghidra and SDK submodules, their bespoke tests, and stale scan exclusions. Retain generic PluginProcess coverage against the repository mock fixture.

* build(vector): remove stale FAISS overrides

* refactor(ipc): remove dormant FSM snapshots

* refactor(ingest): unify bounded KG enqueue

* fix(plugins): harden allocation contracts

Keep ABI outputs null or fully initialized when allocations fail, and represent empty object payloads without relying on malloc(0).

* fix(storage): define empty object reads

Return a null buffer with zero length for successful empty-object reads instead of treating malloc(0) as an allocation contract.

* refactor(storage): trim through string views

* test: prune dormant harness helpers

Remove unused readiness and fixture helpers, and keep benchmark client-count doubling in a wide integer domain.

* chore(cpp): initialize analyzer-facing locals

* build(simeon): advance analyzer cleanup

* refactor(config): drop dead daemon client keys

* refactor(ipc): inject daemon client transport

Socket and in-process execution now share DaemonClient request dispatch, so CLI fallbacks do not need alternate service paths.

* refactor(retrieval): route CLI through daemon

Search and grep now use the same daemon handlers over socket or in-process transport. Live grep remains an explicit freshness inspection.

* refactor(graph): move CLI scope into daemon

Graph CLI requests now share daemon handlers across socket and in-process transports. CWD filtering occurs before pagination so scoped totals and pages stay correct.

* build: isolate test-only runtime controls

* refactor(metadata): decouple lock telemetry

* fix(services): return on request timeout

* fix(retrieval): enforce workspace scope

* fix(graph): scope nodes before pagination

* fix(grep): drop binary result lines

* fix(daemon): bound checkpoint wait

* fix(repair): use KG health checks

* fix(storage): always verify compressed CRC

* perf(vector): skip unchanged PQ snapshots

* build(zyp): exclude bench from default build

* build: protect Meson files from formatter

* refactor: remove unreachable production APIs

* refactor(daemon): remove dormant runtime state

* fix(vector): harden daemon provider calls

Timed-out coroutines must own their client and requests. Provider discovery must also fail instead of returning synthetic embeddings in production.

* refactor(storage): remove unusable adapters

* fix(cli): unify in-process add requests

* fix(tsan): preserve async worker progress

* test(cli): isolate daemon-dependent suites

* fix(repair): expose queue backoff progress
2026-07-31 14:16:45 -04:00

434 lines
15 KiB
C++

/**
* @file hnsw_retrieval_diagnostic.cpp
* @brief Diagnostic test to verify HNSW vector retrieval is working correctly
*
* This test creates a small corpus, generates embeddings, and verifies that
* vector search returns expected results with correct similarity scores.
*
* Run with:
* YAMS_TEST_SAFE_SINGLE_INSTANCE=1 ./builddir/tests/integration/daemon/hnsw_retrieval_diagnostic
*/
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <spdlog/spdlog.h>
#include "tests/integration/daemon/test_async_helpers.h"
#include "tests/integration/daemon/test_daemon_harness.h"
#include <yams/daemon/client/daemon_client.h>
#include <yams/daemon/components/ServiceManager.h>
#include <yams/daemon/daemon.h>
#include <yams/daemon/ipc/ipc_protocol.h>
#include <yams/vector/vector_database.h>
#include <nlohmann/json.hpp>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <set>
#include <thread>
namespace fs = std::filesystem;
using namespace std::chrono_literals;
using namespace yams;
namespace {
// Test documents with known semantic similarity
struct TestDoc {
std::string filename;
std::string content;
std::string category; // For grouping similar docs
};
const std::vector<TestDoc> TEST_CORPUS = {
// Cluster 1: Machine Learning
{"ml_intro.txt",
"Machine learning is a subset of artificial intelligence that enables computers to learn from "
"data. "
"Neural networks are a key component of deep learning systems.",
"ml"},
{"deep_learning.txt",
"Deep learning uses multiple layers of neural networks to process complex patterns. "
"Convolutional neural networks are used for image recognition tasks.",
"ml"},
{"ai_basics.txt",
"Artificial intelligence encompasses machine learning, natural language processing, and "
"robotics. "
"AI systems can learn from experience and improve over time.",
"ml"},
// Cluster 2: Cooking
{"pasta_recipe.txt",
"To make pasta, boil water with salt, add the pasta and cook for 8-10 minutes. "
"Drain and serve with your favorite sauce like marinara or alfredo.",
"cooking"},
{"baking_bread.txt",
"Bread baking requires flour, water, yeast, and salt. Knead the dough for 10 minutes, "
"let it rise for an hour, then bake at 400F for 30 minutes.",
"cooking"},
// Cluster 3: Programming
{"python_intro.txt",
"Python is a high-level programming language known for its readability. "
"It supports object-oriented, functional, and procedural programming paradigms.",
"programming"},
{"cpp_guide.txt",
"C++ is a powerful systems programming language. It provides low-level memory control "
"and high performance for applications like games and operating systems.",
"programming"},
};
// Test queries with expected results
struct TestQuery {
std::string query;
std::vector<std::string> expectedTopFiles; // Files that should be in top results
std::string expectedCategory;
};
const std::vector<TestQuery> TEST_QUERIES = {
{"neural networks and deep learning",
{"ml_intro.txt", "deep_learning.txt", "ai_basics.txt"},
"ml"},
{"how to cook italian food", {"pasta_recipe.txt", "baking_bread.txt"}, "cooking"},
{"software development languages", {"python_intro.txt", "cpp_guide.txt"}, "programming"},
};
// Helper to create a client
daemon::DaemonClient createClient(const fs::path& socketPath) {
daemon::ClientConfig config;
config.socketPath = socketPath;
config.connectTimeout = 5s;
config.requestTimeout = 30s;
return daemon::DaemonClient(config);
}
class HNSWDiagnosticFixture {
public:
void setup() {
spdlog::set_level(spdlog::level::info);
spdlog::info("=== HNSW Retrieval Diagnostic ===");
// Create temp directory for test corpus
tempDir_ = fs::temp_directory_path() / ("hnsw_diag_" + std::to_string(std::time(nullptr)));
fs::create_directories(tempDir_);
// Write test documents
for (const auto& doc : TEST_CORPUS) {
fs::path filePath = tempDir_ / doc.filename;
std::ofstream out(filePath);
out << doc.content;
out.close();
spdlog::info("Created test doc: {}", filePath.string());
}
// Start daemon with real embeddings via ONNX plugin
yams::test::DaemonHarnessOptions opts;
opts.isolateState = true;
opts.enableModelProvider = true;
opts.useMockModelProvider = false; // Use real ONNX embeddings
opts.autoLoadPlugins = true;
opts.configureModelPool = true;
opts.modelPoolLazyLoading = false;
// Resolve plugin directory relative to either the repo root or an existing builddir cwd.
opts.pluginDir = fs::current_path() / "builddir" / "plugins";
if (!fs::exists(*opts.pluginDir)) {
fs::path directBuildPlugins = fs::current_path() / "plugins";
if (fs::exists(directBuildPlugins)) {
opts.pluginDir = std::move(directBuildPlugins);
}
}
opts.preloadModels = {"all-MiniLM-L6-v2"};
// Configure ONNX plugin
nlohmann::json onnxConfig;
onnxConfig["preferred_model"] = "all-MiniLM-L6-v2";
onnxConfig["preload"] = "all-MiniLM-L6-v2";
onnxConfig["keep_model_hot"] = true;
// Prefer env-configured models root; otherwise try common defaults.
// If none exist, omit models_root and let the plugin use its own defaults.
{
fs::path modelsRoot;
if (const char* envModelsRoot = std::getenv("YAMS_MODELS_ROOT");
envModelsRoot && *envModelsRoot) {
modelsRoot = envModelsRoot;
} else {
fs::path macExternal = "/Volumes/picaso/hak/storage/models";
if (fs::exists(macExternal)) {
modelsRoot = macExternal;
} else if (const char* home = std::getenv("HOME"); home && *home) {
fs::path alt = fs::path(home) / ".yams" / "models";
if (fs::exists(alt)) {
modelsRoot = alt;
}
}
}
if (!modelsRoot.empty()) {
onnxConfig["models_root"] = modelsRoot.string();
} else {
SKIP("No ONNX models_root found; skipping semantic retrieval diagnostic");
}
}
opts.pluginConfigs["onnx_plugin"] = onnxConfig.dump();
spdlog::info("Plugin directory: {}", opts.pluginDir->string());
bool started = false;
for (int attempt = 0; attempt < 3 && !started; ++attempt) {
harness_ = std::make_unique<test::DaemonHarness>(opts);
started = harness_->start(60s);
if (!started) {
spdlog::warn("Daemon start attempt {} failed in HNSW diagnostic; retrying",
attempt + 1);
harness_->stop();
harness_.reset();
std::this_thread::sleep_for(500ms);
}
}
if (!started) {
SKIP("Daemon failed to start for HNSW diagnostic after retries (likely TSAN/CI "
"contention)");
}
spdlog::info("Daemon started");
// Ingest test corpus
ingestCorpus();
// Wait for embeddings
waitForEmbeddings();
}
void teardown() {
if (harness_) {
harness_->stop();
}
if (!tempDir_.empty() && fs::exists(tempDir_)) {
fs::remove_all(tempDir_);
}
}
void ingestCorpus() {
// Use the ServiceManager directly for simpler ingestion
auto* daemon = harness_->daemon();
REQUIRE(daemon != nullptr);
auto* sm = daemon->getServiceManager();
REQUIRE(sm != nullptr);
auto contentStore = sm->getContentStore();
auto metaRepo = sm->getMetadataRepo();
REQUIRE(contentStore != nullptr);
REQUIRE(metaRepo != nullptr);
spdlog::info("Ingesting corpus from: {}", tempDir_.string());
for (const auto& doc : TEST_CORPUS) {
fs::path filePath = tempDir_ / doc.filename;
// Read file content
std::ifstream in(filePath, std::ios::binary);
std::vector<std::byte> bytes;
char c;
while (in.get(c)) {
bytes.push_back(static_cast<std::byte>(c));
}
// Store in content store
auto storeResult = contentStore->storeBytes(bytes);
REQUIRE(storeResult.has_value());
std::string hash = storeResult.value().contentHash;
// Create document metadata
yams::metadata::DocumentInfo docInfo;
docInfo.fileName = doc.filename;
docInfo.filePath = filePath.string();
docInfo.sha256Hash = hash;
docInfo.fileSize = bytes.size();
docInfo.mimeType = "text/plain";
docInfo.fileExtension = ".txt";
docInfo.modifiedTime = std::chrono::time_point_cast<std::chrono::seconds>(
std::chrono::system_clock::now());
auto insertResult = metaRepo->insertDocument(docInfo);
REQUIRE(insertResult.has_value());
spdlog::info("Ingested: {} (hash: {})", doc.filename, hash.substr(0, 8));
}
spdlog::info("Corpus ingestion complete");
}
void waitForEmbeddings() {
spdlog::info("Waiting for embedding generation (max 30s)...");
auto* daemon = harness_->daemon();
REQUIRE(daemon != nullptr);
auto* sm = daemon->getServiceManager();
REQUIRE(sm != nullptr);
// Poll stats until we have embeddings for all docs (or timeout)
for (int i = 0; i < 30; ++i) { // Max 30 seconds
auto vectorDb = sm->getVectorDatabase();
if (vectorDb) {
vectorCount_ = vectorDb->getVectorCount();
if (i % 5 == 0) {
spdlog::info("Vector count: {} (waiting for {})", vectorCount_,
TEST_CORPUS.size());
}
// We expect at least one vector per document
if (vectorCount_ >= TEST_CORPUS.size()) {
spdlog::info("Embeddings ready: {} vectors for {} docs", vectorCount_,
TEST_CORPUS.size());
hasEmbeddings_ = true;
return;
}
}
std::this_thread::sleep_for(1000ms);
}
// Don't fail - we can still test keyword search without embeddings
spdlog::warn("Embeddings not generated after 30s - continuing with keyword-only tests");
hasEmbeddings_ = false;
}
daemon::SearchResponse runSearch(const std::string& query, const std::string& searchType,
int limit = 10) {
auto client = createClient(harness_->socketPath());
// Connect first
auto connectResult = cli::run_sync(client.connect(), 5s);
REQUIRE(connectResult.has_value());
daemon::SearchRequest searchReq;
searchReq.query = query;
searchReq.searchType = searchType;
searchReq.limit = limit;
searchReq.timeout = 10s;
auto result = cli::run_sync(client.search(searchReq), 15s);
REQUIRE(result.has_value());
return result.value();
}
void runDiagnostics() {
spdlog::info("\n=== Running Search Diagnostics ===\n");
// Check if embeddings are actually available before testing semantic search.
if (!hasEmbeddings_ || vectorCount_ == 0) {
SKIP("ONNX models not available - skipping semantic search diagnostic");
}
for (const auto& tq : TEST_QUERIES) {
spdlog::info("Query: \"{}\"", tq.query);
spdlog::info("Expected category: {}", tq.expectedCategory);
std::string expectedFiles;
for (const auto& f : tq.expectedTopFiles) {
if (!expectedFiles.empty())
expectedFiles += ", ";
expectedFiles += f;
}
spdlog::info("Expected top files: {}", expectedFiles);
// Test semantic/vector search
spdlog::info("\n--- Vector Search ---");
auto vectorResults = runSearch(tq.query, "semantic", 5);
printResults(vectorResults, "vector");
// Test keyword search
spdlog::info("\n--- Keyword Search ---");
auto keywordResults = runSearch(tq.query, "keyword", 5);
printResults(keywordResults, "keyword");
// Test hybrid search
spdlog::info("\n--- Hybrid Search ---");
auto hybridResults = runSearch(tq.query, "hybrid", 5);
printResults(hybridResults, "hybrid");
// Analyze results
analyzeResults(tq, vectorResults, keywordResults, hybridResults);
spdlog::info("\n{}\n", std::string(60, '='));
}
}
void printResults(const daemon::SearchResponse& results, const std::string& searchType) {
if (results.results.empty()) {
spdlog::warn(" {} search: NO RESULTS", searchType);
return;
}
int rank = 1;
for (const auto& r : results.results) {
fs::path p(r.path);
std::string filename = p.filename().string();
spdlog::info(" #{}: {} (score={:.4f})", rank, filename, r.score);
rank++;
}
}
void analyzeResults(const TestQuery& tq, const daemon::SearchResponse& vectorRes,
const daemon::SearchResponse& /*keywordRes*/,
const daemon::SearchResponse& /*hybridRes*/) {
spdlog::info("\n--- Analysis ---");
// Check if expected files are in vector results
std::set<std::string> vectorFiles;
for (const auto& r : vectorRes.results) {
fs::path p(r.path);
vectorFiles.insert(p.filename().string());
}
int vectorHits = 0;
for (const auto& expected : tq.expectedTopFiles) {
if (vectorFiles.count(expected)) {
vectorHits++;
}
}
float vectorRecall = static_cast<float>(vectorHits) / tq.expectedTopFiles.size();
spdlog::info("Vector recall: {}/{} = {:.1f}%", vectorHits, tq.expectedTopFiles.size(),
vectorRecall * 100);
// Check vector score distribution
if (!vectorRes.results.empty()) {
float topScore = static_cast<float>(vectorRes.results[0].score);
float bottomScore = static_cast<float>(vectorRes.results.back().score);
spdlog::info("Vector score range: [{:.4f}, {:.4f}]", bottomScore, topScore);
}
// Flag potential issues
if (vectorFiles.empty()) {
spdlog::error("ISSUE: Vector search returned NO results!");
} else if (vectorRecall < 0.5f) {
spdlog::warn("ISSUE: Low vector recall ({:.1f}%)", vectorRecall * 100);
}
}
private:
std::unique_ptr<test::DaemonHarness> harness_;
fs::path tempDir_;
bool hasEmbeddings_ = false;
size_t vectorCount_ = 0;
};
} // namespace
TEST_CASE("HNSW Retrieval Diagnostic", "[hnsw][diagnostic][integration]") {
HNSWDiagnosticFixture fixture;
fixture.setup();
fixture.runDiagnostics();
fixture.teardown();
}