yams/tests/integration/search/hybrid_search_integration_test.cpp
Trevon d398556ee5
fix(tests): replace frozen expectations and harden release gates (#323)
* test(search,daemon,storage): assert behaviour instead of frozen literals

Several suites encoded exact internal values that legitimate tuning and
ranking changes are allowed to move: SCIENTIFIC-era profile weights and
rrfK values, corpus-statistic figures, and hardcoded timeout/cancellation
numbers. They failed on every platform after those changes landed, so the
real behaviour regressions they should catch were hidden behind noise.

Replace the frozen literals with contract assertions: weights are finite,
non-negative and normalize, state selection still holds, capability-gated
sources stay zeroed, and hybrid results keep matching canonical hash/path
sets. Deadlines are not widened and the daemon paths they cover are
unchanged.

Also add independent corpus and adapter oracles, deterministic collector
cancellation and late-result gates, and synchronize ServiceManager
shutdown observation, so the tests fail for the behaviour they name.

The portability allowlist keeps its two reviewed raw test-env reads; only
their line numbers moved.

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

* ci(tests): summarise Meson failures without flooding the log

A failing test run printed the whole Meson suite output, so the actual
failing test names and their assertion messages were buried and the
uploaded logs were dominated by passing-test chatter.

Add a bounded reporter that extracts the failing test names plus their
surrounding diagnostics, keeps an overall cap on emitted lines, and is
unit-tested against representative Meson output. Wire it into the Tests
workflow so failures stay readable.

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

* build(deb): validate candidate identity, mapping and upgrade ordering

The Debian packaging scripts took filenames and versions on trust, so a
misnamed candidate or a wrong package ordering could pass validation while
the real install path was never exercised.

Fail closed on candidate identity, version and architecture, verify the
package-to-source mapping, seed the legacy-only layout, wait on the actual
services, and assert install/upgrade/remove ordering for both Debian and
RPM in an offline container. Cases that cannot be proven on a host are
reported as skipped rather than silently passing.

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

---------

Signed-off-by: trvon <git@trevon.dev>
2026-10-19 19:16:57 -06:00

460 lines
17 KiB
C++

// Integration tests for semantic/hybrid daemon request availability and model-free lexical
// fallback through the public hybrid search request.
#include "tests/integration/daemon/test_async_helpers.h"
#include "tests/integration/daemon/test_daemon_harness.h"
#include <catch2/catch_test_macros.hpp>
#include <yams/daemon/client/daemon_client.h>
#include <yams/daemon/components/ServiceManager.h>
#include <yams/daemon/daemon.h>
#include <yams/metadata/metadata_repository.h>
#include <array>
#include <chrono>
#include <cmath>
#include <filesystem>
#include <fstream>
#include <map>
#include <memory>
#include <set>
#include <stdexcept>
#include <string>
#include <thread>
#include <vector>
// Windows daemon IPC tests are unstable due to socket shutdown race conditions
#ifdef _WIN32
#define SKIP_ON_WINDOWS_DAEMON_SHUTDOWN() \
SKIP("Daemon IPC tests unstable on Windows - see windows-daemon-ipc-plan.md")
#else
#define SKIP_ON_WINDOWS_DAEMON_SHUTDOWN() ((void)0)
#endif
using namespace yams::daemon;
using namespace std::chrono_literals;
namespace yams::test {
// Fixture for integration tests requiring full daemon with hybrid search infrastructure
class HybridSearchIntegrationFixture {
public:
struct SeededDocument {
std::string hash;
std::string path;
};
explicit HybridSearchIntegrationFixture(bool enableModelProvider = true)
: harness_(makeHarnessOptions(enableModelProvider)) {
// Start daemon with the requested search infrastructure.
bool started = harness_.start(std::chrono::seconds(30));
if (!started) {
throw std::runtime_error("Failed to start daemon for hybrid search integration tests");
}
// Create daemon client
ClientConfig clientCfg;
clientCfg.socketPath = harness_.socketPath();
clientCfg.connectTimeout = 5s;
clientCfg.autoStart = false;
spdlog::info("[HybridSearchFixture] Creating DaemonClient for socket: {}",
clientCfg.socketPath.string());
client_ = std::make_unique<DaemonClient>(clientCfg);
// Initial delay for GlobalIOContext threads to stabilize
std::this_thread::sleep_for(std::chrono::milliseconds(200));
// Connect to daemon with retry logic for GlobalIOContext timing
spdlog::info("[HybridSearchFixture] Calling client_->connect()...");
Result<void> connectResult;
constexpr int maxRetries = 3;
for (int attempt = 0; attempt < maxRetries; ++attempt) {
connectResult = cli::run_sync(client_->connect(), 5s);
if (connectResult) {
break;
}
spdlog::warn("[HybridSearchFixture] connect() attempt {} failed: {}, retrying...",
attempt + 1, connectResult.error().message);
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
if (!connectResult) {
spdlog::error("[HybridSearchFixture] connect() failed after {} attempts: {}",
maxRetries, connectResult.error().message);
throw std::runtime_error("Failed to connect to daemon: " +
connectResult.error().message);
}
spdlog::info("[HybridSearchFixture] connect() succeeded");
// Verify daemon is ready (search infrastructure available)
auto statusResult = cli::run_sync(client_->status(), 5s);
if (!statusResult) {
throw std::runtime_error("Failed to get daemon status: " +
statusResult.error().message);
}
// Note: Mock provider may show embeddings as unavailable but daemon is ready
// This is expected for integration tests using mock model provider
}
~HybridSearchIntegrationFixture() {
// Cleanup: disconnect and stop daemon
if (client_) {
client_->disconnect();
}
harness_.stop();
}
// Execute search via daemon client
Result<SearchResponse> executeSearch(const SearchRequest& req) {
return cli::run_sync(client_->search(req), 10s);
}
DaemonClient* client() { return client_.get(); }
const std::filesystem::path& dataDir() const { return harness_.dataDir(); }
std::vector<SeededDocument> seedModelFreeHybridCorpus() {
struct FixtureSpec {
std::string name;
std::string content;
std::vector<std::string> tags;
};
const std::array<FixtureSpec, 3> fixtures{{
{"hybrid_red_alpha.txt",
"hybridfallbacksentinel red alpha alpha alpha document",
{"red", "shared"}},
{"hybrid_red_beta.txt",
"hybridfallbacksentinel red beta beta document",
{"red", "shared"}},
{"hybrid_blue_gamma.txt",
"hybridfallbacksentinel blue gamma document",
{"blue", "shared"}},
}};
auto* daemon = harness_.daemon();
REQUIRE(daemon != nullptr);
auto* serviceManager = daemon->getServiceManager();
REQUIRE(serviceManager != nullptr);
auto metadataRepo = serviceManager->getMetadataRepo();
REQUIRE(metadataRepo != nullptr);
std::vector<SeededDocument> seeded;
seeded.reserve(fixtures.size());
for (const auto& fixture : fixtures) {
const auto path = harness_.dataDir() / fixture.name;
std::ofstream output(path, std::ios::binary | std::ios::trunc);
REQUIRE(output.is_open());
output << fixture.content;
output.close();
REQUIRE(output.good());
AddDocumentRequest request;
request.path = path.string();
request.name = fixture.name;
request.content = fixture.content;
request.tags = fixture.tags;
request.noEmbeddings = true;
request.waitForProcessing = true;
auto addResult = cli::run_sync(client_->streamingAddDocument(request), 10s);
REQUIRE(addResult);
REQUIRE_FALSE(addResult.value().hash.empty());
auto documentResult = metadataRepo->getDocumentByHash(addResult.value().hash);
REQUIRE(documentResult);
REQUIRE(documentResult.value().has_value());
const auto& document = *documentResult.value();
auto indexResult = metadataRepo->indexDocumentContent(document.id, fixture.name,
fixture.content, "text/plain");
REQUIRE(indexResult);
REQUIRE(document.fileName == fixture.name);
REQUIRE(document.sha256Hash == addResult.value().hash);
const auto expectedPath = std::filesystem::canonical(path).string();
REQUIRE(document.filePath == expectedPath);
seeded.push_back({addResult.value().hash, expectedPath});
}
return seeded;
}
private:
static DaemonHarness::Options makeHarnessOptions(bool enableModelProvider) {
DaemonHarness::Options options;
options.enableModelProvider = enableModelProvider;
options.useMockModelProvider = enableModelProvider;
options.autoLoadPlugins = false;
if (!enableModelProvider) {
options.isolateEnvironment = true;
options.configureDaemon = [](DaemonConfig& config) {
config.enableModelProvider = false;
config.modelProviderRequired = false;
config.autoLoadPlugins = false;
config.downloadPolicy.enable = false;
};
}
return options;
}
DaemonHarness harness_;
std::unique_ptr<daemon::DaemonClient> client_;
};
// =============================================================================
// Semantic Search Availability Tests
// =============================================================================
TEST_CASE("SemanticSearch - Infrastructure availability", "[integration][search][semantic]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
HybridSearchIntegrationFixture fixture;
// Test that daemon is running and can respond to semantic search requests
// With mock provider, semantic search may return "unavailable" but should not crash
SearchRequest req;
req.query = "indexing pipeline";
req.searchType = "semantic";
req.limit = 10;
req.timeout = 2s; // Short timeout for mock provider
auto result = fixture.executeSearch(req);
// With mock provider, semantic search infrastructure is unavailable
// This test verifies the daemon handles semantic requests gracefully
// Real test would verify actual embedding-based search results
if (!result) {
// Expected: InvalidState when embeddings unavailable
REQUIRE(result.error().code == ErrorCode::InvalidState);
REQUIRE(result.error().message.find("not ready") != std::string::npos);
} else {
// If embeddings become available, verify response structure
REQUIRE(result.value().results.size() <= 10);
}
}
TEST_CASE("SemanticSearch - Similarity option availability smoke",
"[integration][search][semantic]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
HybridSearchIntegrationFixture fixture;
// Exercise a semantic request carrying a high similarity option. With no seeded corpus this
// checks request handling, not retrieval quality or threshold recall.
SearchRequest req;
req.query = "document indexing";
req.searchType = "semantic";
req.similarity = 0.9f; // Very high threshold - only very similar results
req.limit = 10;
req.timeout = 2s; // Short timeout for mock provider
auto result = fixture.executeSearch(req);
// With mock provider, expect InvalidState
if (!result) {
REQUIRE(result.error().code == ErrorCode::InvalidState);
} else {
// Preserve the optional semantic-provider behavior when one is available.
for (const auto& hit : result.value().results) {
REQUIRE(hit.score >= 0.9f);
}
}
}
TEST_CASE("SemanticSearch - Empty query validation",
"[integration][search][semantic][validation]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
HybridSearchIntegrationFixture fixture;
// Empty query should fail validation at daemon level
SearchRequest req;
req.query = "";
req.searchType = "semantic";
req.limit = 10;
req.timeout = 2s; // Short timeout
auto result = fixture.executeSearch(req);
// Should reject empty query
REQUIRE_FALSE(result);
REQUIRE(result.error().message.find("required") != std::string::npos);
}
TEST_CASE("SemanticSearch - Very long query handling", "[integration][search][semantic][edge]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
HybridSearchIntegrationFixture fixture;
// Test with very long query (stress test embedding provider)
std::string longQuery(5000, 'a');
longQuery += " indexing pipeline search";
SearchRequest req;
req.query = longQuery;
req.searchType = "semantic";
req.limit = 10;
req.timeout = 2s; // Short timeout for mock provider
auto result = fixture.executeSearch(req);
// Should handle gracefully (either succeed or return InvalidState)
if (!result) {
// With mock provider or query too long
REQUIRE((result.error().code == ErrorCode::InvalidState ||
result.error().code == ErrorCode::InvalidArgument));
} else {
REQUIRE(result.value().results.size() <= 10);
}
}
// =============================================================================
// Hybrid Search Tests
// =============================================================================
TEST_CASE("HybridSearch - Request availability smoke", "[integration][search][hybrid]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
HybridSearchIntegrationFixture fixture;
// Exercise the public hybrid request. The unseeded corpus cannot establish two-leg fusion.
SearchRequest req;
req.query = "indexing pipeline";
req.searchType = "hybrid";
req.limit = 10;
req.timeout = 2s; // Short timeout for mock provider
auto result = fixture.executeSearch(req);
// With mock provider, hybrid search falls back to keyword-only
if (!result) {
REQUIRE(result.error().code == ErrorCode::InvalidState);
} else {
// If the infrastructure is available, verify only the public response ordering contract.
REQUIRE(result.value().results.size() <= 10);
for (size_t i = 1; i < result.value().results.size(); ++i) {
REQUIRE(result.value().results[i - 1].score >= result.value().results[i].score);
}
}
}
TEST_CASE("HybridSearch - Keyword fallback availability smoke",
"[integration][search][hybrid][fallback]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
HybridSearchIntegrationFixture fixture;
// Exercise the request when vector search may be unavailable. With no seeded corpus this is
// only an availability smoke for the keyword fallback route.
SearchRequest req;
req.query = "pipeline";
req.searchType = "hybrid";
req.limit = 5;
req.timeout = 2s; // Short timeout for mock provider
auto result = fixture.executeSearch(req);
// Accept either the configured fallback response or an explicit unavailable response.
if (result) {
REQUIRE(result.value().results.size() <= 5);
} else {
REQUIRE(result.error().code == ErrorCode::InvalidState);
}
}
TEST_CASE("HybridSearch - Model-free seeded lexical fallback behavior",
"[integration][search][hybrid][fallback]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
// This deliberately covers lexical fallback through a hybrid request, not semantic inference
// or two-leg RRF fusion.
HybridSearchIntegrationFixture fixture(/*enableModelProvider=*/false);
const auto seeded = fixture.seedModelFreeHybridCorpus();
REQUIRE(seeded.size() == 3);
// Fusion preserves content hash/path identity, not hydrated database row IDs or titles.
// Check the canonical public identity rather than assuming optional fields are hydrated.
std::map<std::string, HybridSearchIntegrationFixture::SeededDocument> expectedByHash;
for (const auto& document : seeded) {
expectedByHash.emplace(document.hash, document);
}
REQUIRE(expectedByHash.size() == 3);
const auto contentHash = [](const SearchResult& hit) -> const std::string& {
const auto hash = hit.metadata.find("hash");
REQUIRE(hash != hit.metadata.end());
return hash->second;
};
SearchRequest request;
request.query = "hybridfallbacksentinel";
request.searchType = "hybrid";
request.globalSearch = true;
request.limit = 10;
auto allResult = fixture.executeSearch(request);
REQUIRE(allResult);
const auto& allHits = allResult.value().results;
REQUIRE(allHits.size() == 3);
REQUIRE(allHits.size() >= 2);
std::set<std::string> actualHashes;
for (const auto& hit : allHits) {
const auto& hash = contentHash(hit);
CAPTURE(hash, hit.path);
REQUIRE(std::isfinite(hit.score));
const auto expected = expectedByHash.find(hash);
REQUIRE(expected != expectedByHash.end());
REQUIRE(hit.path == expected->second.path);
actualHashes.insert(hash);
}
std::set<std::string> expectedHashes;
for (const auto& document : seeded) {
expectedHashes.insert(document.hash);
}
REQUIRE(actualHashes == expectedHashes);
for (std::size_t i = 1; i < allHits.size(); ++i) {
REQUIRE(allHits[i - 1].score >= allHits[i].score);
}
request.tags = {"red", "shared"};
request.matchAllTags = true;
auto redResult = fixture.executeSearch(request);
REQUIRE(redResult);
REQUIRE(redResult.value().results.size() == 2);
const std::set<std::string> expectedRedHashes{seeded[0].hash, seeded[1].hash};
std::set<std::string> actualRedHashes;
for (const auto& hit : redResult.value().results) {
actualRedHashes.insert(contentHash(hit));
}
REQUIRE(actualRedHashes == expectedRedHashes);
request.tags = {"absent-hybrid-contract-tag"};
auto absentResult = fixture.executeSearch(request);
REQUIRE(absentResult);
REQUIRE(absentResult.value().results.empty());
request.tags.clear();
request.matchAllTags = false;
request.limit = 1;
auto limitedResult = fixture.executeSearch(request);
REQUIRE(limitedResult);
REQUIRE(limitedResult.value().results.size() == 1);
const auto& limitedHit = limitedResult.value().results.front();
REQUIRE(contentHash(limitedHit) == contentHash(allHits.front()));
REQUIRE(limitedHit.path == allHits.front().path);
}
TEST_CASE("HybridSearch - Engine unavailable detection", "[integration][search][hybrid][error]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
HybridSearchIntegrationFixture fixture;
// Test that hybrid search properly detects when hybrid engine is unavailable
SearchRequest req;
req.query = "pipeline";
req.searchType = "hybrid";
req.limit = 10;
req.timeout = 2s; // Short timeout for mock provider
auto result = fixture.executeSearch(req);
// Without hybrid engine, should return InvalidState error
if (!result) {
REQUIRE(result.error().code == ErrorCode::InvalidState);
REQUIRE(result.error().message.find("not ready") != std::string::npos);
} else {
// If hybrid engine becomes available, verify response structure
REQUIRE(result.value().results.size() <= 10);
}
}
} // namespace yams::test