* 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
434 lines
15 KiB
C++
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();
|
|
}
|