yams/tests/integration/search/fsm_tuning_benchmark_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

593 lines
21 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright 2025 YAMS Contributors
/**
* @file fsm_tuning_benchmark_integration_test.cpp
* @brief Integration tests for FSM tuning with internal benchmark validation
*
* Phase 4 of Epic yams-7ez4: Tests the complete loop between:
* - CorpusStats collection from real corpus
* - SearchTuner FSM state selection based on corpus metrics
* - InternalBenchmark validation of tuned search quality
* - BenchmarkComparison for regression/improvement detection
*
* These tests validate that the adaptive tuning system works correctly
* with real documents and the actual search infrastructure.
*/
#include "tests/integration/daemon/test_async_helpers.h"
#include "tests/integration/daemon/test_daemon_harness.h"
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include <yams/daemon/client/daemon_client.h>
#include <yams/metadata/metadata_repository.h>
#include <yams/search/internal_benchmark.h>
#include <yams/search/search_engine.h>
#include <yams/search/search_tuner.h>
#include <yams/storage/corpus_stats.h>
#include <array>
#include <chrono>
#include <cmath>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
// Windows daemon IPC tests are unstable due to socket shutdown race conditions
#ifdef _WIN32
#define SKIP_ON_WINDOWS_DAEMON() \
SKIP("Daemon IPC tests unstable on Windows - see windows-daemon-ipc-plan.md")
#else
#define SKIP_ON_WINDOWS_DAEMON() ((void)0)
#endif
using namespace yams;
using namespace yams::daemon;
using namespace yams::search;
using namespace yams::storage;
using namespace yams::metadata;
using namespace std::chrono_literals;
using Catch::Approx;
using Catch::Matchers::ContainsSubstring;
namespace yams::test {
// =============================================================================
// Test Fixture: Provides daemon + search infrastructure for integration tests
// =============================================================================
class FSMTuningIntegrationFixture {
public:
FSMTuningIntegrationFixture() : harness_(makeHarnessOptions()) {
// Start daemon with full search infrastructure
bool started = false;
for (int attempt = 0; attempt < 3; ++attempt) {
started = harness_.start(std::chrono::seconds(30));
if (started) {
break;
}
harness_.stop();
std::this_thread::sleep_for(std::chrono::milliseconds(250));
}
if (!started) {
throw std::runtime_error("Failed to start daemon for FSM tuning integration tests");
}
// Create daemon client
ClientConfig clientCfg;
clientCfg.socketPath = harness_.socketPath();
clientCfg.connectTimeout = 5s;
clientCfg.autoStart = false;
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
Result<void> connectResult;
constexpr int maxRetries = 3;
for (int attempt = 0; attempt < maxRetries; ++attempt) {
connectResult = cli::run_sync(client_->connect(), 5s);
if (connectResult) {
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
if (!connectResult) {
throw std::runtime_error("Failed to connect to daemon: " +
connectResult.error().message);
}
// Wait for daemon to be ready
auto statusResult = cli::run_sync(client_->status(), 5s);
if (!statusResult) {
throw std::runtime_error("Failed to get daemon status: " +
statusResult.error().message);
}
}
~FSMTuningIntegrationFixture() {
if (client_) {
client_->disconnect();
}
harness_.stop();
}
// Ingest a test file into the daemon
Result<std::string> ingestFile(const std::string& filename, const std::string& content) {
// Create temp file
auto filePath = harness_.dataDir() / filename;
std::ofstream ofs(filePath);
if (!ofs) {
return Error{ErrorCode::IOError, "Failed to create test file"};
}
ofs << content;
ofs.close();
// Ingest via daemon client using streaming add
AddDocumentRequest req;
req.path = filePath.string();
req.tags = {"test", "integration"};
auto result = cli::run_sync(client_->streamingAddDocument(req), 10s);
if (!result) {
return Error{result.error().code, result.error().message};
}
return result.value().hash;
}
// Execute search via daemon client
Result<daemon::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(); }
private:
static DaemonHarness::Options makeHarnessOptions() {
DaemonHarness::Options opts;
opts.enableModelProvider = false;
opts.useMockModelProvider = false;
return opts;
}
DaemonHarness harness_;
std::unique_ptr<DaemonClient> client_;
};
// =============================================================================
// Helper: Generate test documents with different characteristics
// =============================================================================
std::string generateCodeDocument(size_t functions = 5) {
std::string code = R"(// Auto-generated test code file
#include <vector>
#include <string>
#include <algorithm>
namespace yams::test {
)";
for (size_t i = 0; i < functions; ++i) {
code += "/**\n";
code += " * @brief Function " + std::to_string(i) + " for testing search indexing\n";
code += " * @param input The input vector to process\n";
code += " * @return Processed result vector\n";
code += " */\n";
code += "std::vector<int> processFunction" + std::to_string(i) + "(";
code += "const std::vector<int>& input) {\n";
code += " std::vector<int> result;\n";
code += " result.reserve(input.size());\n";
code += " for (const auto& val : input) {\n";
code += " result.push_back(val * " + std::to_string(i + 1) + ");\n";
code += " }\n";
code += " return result;\n";
code += "}\n\n";
}
code += "} // namespace yams::test\n";
return code;
}
std::string generateProseDocument(size_t paragraphs = 3) {
std::string prose;
for (size_t i = 0; i < paragraphs; ++i) {
prose += "# Section " + std::to_string(i + 1) + "\n\n";
prose += "This is paragraph " + std::to_string(i + 1) + " of the test document. ";
prose += "It contains information about search engine optimization and "
"retrieval quality. ";
prose += "The document discusses various topics including indexing, "
"ranking algorithms, ";
prose += "and benchmark evaluation metrics like MRR and Recall@K.\n\n";
prose += "Additional context about information retrieval systems and their ";
prose += "performance characteristics in different corpus environments.\n\n";
}
return prose;
}
// =============================================================================
// Test: CorpusStats collection from real indexed documents
// =============================================================================
TEST_CASE("FSM Tuning Integration: CorpusStats reflects ingested documents",
"[integration][search][fsm][corpus_stats]") {
SKIP_ON_WINDOWS_DAEMON();
FSMTuningIntegrationFixture fixture;
// Ingest some code files
for (int i = 0; i < 3; ++i) {
auto result =
fixture.ingestFile("test_code_" + std::to_string(i) + ".cpp", generateCodeDocument(5));
REQUIRE(result.has_value());
}
// Ingest some prose files
for (int i = 0; i < 2; ++i) {
auto result =
fixture.ingestFile("test_doc_" + std::to_string(i) + ".md", generateProseDocument(3));
REQUIRE(result.has_value());
}
// Wait for indexing to complete
std::this_thread::sleep_for(1s);
// Verify corpus stats via status
auto statusResult = cli::run_sync(fixture.client()->status(), 5s);
REQUIRE(statusResult.has_value());
// The daemon should report document count
INFO("Daemon status after ingestion verified");
}
// =============================================================================
// Test: SearchTuner produces correct state for different corpus profiles
// =============================================================================
TEST_CASE("FSM Tuning Integration: SearchTuner state transitions",
"[integration][search][fsm][tuner]") {
// State selection and usable normalized output are contracts; the exact
// benchmark-driven profile weights are not.
const auto checkWeights = [](const SearchTuner& tuner) {
const auto params = tuner.getParams();
const std::array weights = {
params.weights.text.value, params.weights.simeonText.value,
params.weights.vector.value, params.weights.entityVector.value,
params.weights.pathTree.value, params.weights.kg.value,
params.weights.tag.value, params.weights.metadata.value,
};
float total = 0.0f;
for (std::size_t i = 0; i < weights.size(); ++i) {
CAPTURE(i, weights[i]);
CHECK(std::isfinite(weights[i]));
CHECK(weights[i] >= 0.0f);
total += weights[i];
}
CHECK(total == Approx(1.0f));
};
SECTION("Code-heavy corpus selects CODE state") {
CorpusStats stats;
stats.docCount = 500;
stats.codeRatio = 0.85;
stats.proseRatio = 0.10;
stats.symbolDensity = 0.5;
stats.pathDepthAvg = 3.0;
stats.tagCoverage = 0.2;
SearchTuner tuner(stats);
// With code ratio > 0.7 and doc count < 1000, should be SMALL_CODE
CHECK(tuner.currentState() == TuningState::SMALL_CODE);
checkWeights(tuner);
}
SECTION("Prose-heavy corpus selects PROSE state") {
CorpusStats stats;
stats.docCount = 500;
stats.codeRatio = 0.10;
stats.proseRatio = 0.85;
stats.symbolDensity = 0.01;
stats.pathDepthAvg = 2.0;
stats.pathRelativeDepthAvg = 3.0; // Deep nesting disqualifies isScientific
stats.nativeSymbolDensity = 0.0;
stats.tagCoverage = 0.3;
SearchTuner tuner(stats);
// With prose ratio > 0.7 and doc count < 1000, should be SMALL_PROSE
CHECK(tuner.currentState() == TuningState::SMALL_PROSE);
checkWeights(tuner);
}
SECTION("Scientific corpus (prose without path/tag structure)") {
CorpusStats stats;
stats.docCount = 1500; // Must be >= 1000 (not small) for SCIENTIFIC
stats.codeRatio = 0.05;
stats.proseRatio = 0.90;
stats.symbolDensity = 0.01;
stats.pathDepthAvg = 1.0;
stats.pathRelativeDepthAvg = 0.5; // Flat structure triggers isScientific
stats.nativeSymbolDensity = 0.01; // Low code structure
stats.tagCoverage = 0.05; // Minimal tags
SearchTuner tuner(stats);
CHECK(tuner.currentState() == TuningState::SCIENTIFIC);
checkWeights(tuner);
// This corpus has no usable structure signals. These zeros express
// capability gates, not a preferred text/vector/Simeon blend.
const auto params = tuner.getParams();
CHECK(params.weights.pathTree.value == 0.0f);
CHECK(params.weights.kg.value == 0.0f);
CHECK(params.weights.tag.value == 0.0f);
}
SECTION("Large mixed corpus") {
CorpusStats stats;
stats.docCount = 5000;
stats.codeRatio = 0.45;
stats.proseRatio = 0.40;
stats.symbolDensity = 0.2;
stats.pathDepthAvg = 4.0;
stats.tagCoverage = 0.5;
SearchTuner tuner(stats);
// Mixed content, neither code nor prose dominant
CHECK((tuner.currentState() == TuningState::MIXED));
checkWeights(tuner);
}
SECTION("Minimal corpus") {
CorpusStats stats;
stats.docCount = 50; // < 100
stats.codeRatio = 0.50;
stats.proseRatio = 0.30;
stats.symbolDensity = 0.1;
stats.pathDepthAvg = 2.0;
stats.tagCoverage = 0.1;
SearchTuner tuner(stats);
CHECK((tuner.currentState() == TuningState::MINIMAL));
checkWeights(tuner);
}
}
// =============================================================================
// Test: TunedParams apply correctly to SearchEngineConfig
// =============================================================================
TEST_CASE("FSM Tuning Integration: TunedParams apply to config",
"[integration][search][fsm][config]") {
CorpusStats stats;
stats.docCount = 2000;
stats.codeRatio = 0.75;
stats.proseRatio = 0.15;
stats.symbolDensity = 0.4;
stats.pathDepthAvg = 4.0;
stats.tagCoverage = 0.3;
SearchTuner tuner(stats);
auto config = tuner.getConfig();
// Verify config has tuned weights applied
CHECK((config.textWeight == tuner.getParams().weights.text.value));
CHECK((config.simeonTextWeight == tuner.getParams().weights.simeonText.value));
CHECK((config.vectorWeight == tuner.getParams().weights.vector.value));
CHECK((config.entityVectorWeight == tuner.getParams().weights.entityVector.value));
CHECK((config.rrfK == static_cast<float>(tuner.getRrfK())));
CHECK((config.pathTreeWeight == tuner.getParams().weights.pathTree.value));
CHECK((config.kgWeight == tuner.getParams().weights.kg.value));
CHECK((config.tagWeight == tuner.getParams().weights.tag.value));
CHECK((config.metadataWeight == tuner.getParams().weights.metadata.value));
// Verify JSON serialization for observability
auto json = tuner.toJson();
CHECK(json.contains("state"));
CHECK(json.contains("params"));
CHECK(json.contains("reason"));
CHECK((json["state"].get<std::string>() == tuningStateToString(tuner.currentState())));
}
// =============================================================================
// Test: BenchmarkComparison detects regression and improvement
// =============================================================================
TEST_CASE("FSM Tuning Integration: BenchmarkComparison detection",
"[integration][search][fsm][benchmark][comparison]") {
SECTION("Detects significant improvement") {
BenchmarkResults baseline;
baseline.mrr = 0.60f;
baseline.recallAtK = 0.55f;
baseline.latency.meanMs = 50.0;
BenchmarkResults current;
current.mrr = 0.75f; // +0.15 improvement
current.recallAtK = 0.70f;
current.latency.meanMs = 45.0;
auto comparison = InternalBenchmark::compare(baseline, current, 0.05f);
CHECK((comparison.isImprovement == true));
CHECK((comparison.isRegression == false));
CHECK((comparison.mrrDelta == Approx(0.15f)));
CHECK((comparison.recallDelta == Approx(0.15f)));
CHECK_THAT(comparison.summary, ContainsSubstring("IMPROVEMENT"));
}
SECTION("Detects significant regression") {
BenchmarkResults baseline;
baseline.mrr = 0.80f;
baseline.recallAtK = 0.75f;
baseline.latency.meanMs = 40.0;
BenchmarkResults current;
current.mrr = 0.65f; // -0.15 regression
current.recallAtK = 0.60f;
current.latency.meanMs = 60.0;
auto comparison = InternalBenchmark::compare(baseline, current, 0.05f);
CHECK((comparison.isRegression == true));
CHECK((comparison.isImprovement == false));
CHECK((comparison.mrrDelta == Approx(-0.15f)));
CHECK_THAT(comparison.summary, ContainsSubstring("REGRESSION"));
}
SECTION("No significant change within threshold") {
BenchmarkResults baseline;
baseline.mrr = 0.70f;
baseline.recallAtK = 0.65f;
baseline.latency.meanMs = 50.0;
BenchmarkResults current;
current.mrr = 0.72f; // +0.02, below 0.05 threshold
current.recallAtK = 0.66f;
current.latency.meanMs = 48.0;
auto comparison = InternalBenchmark::compare(baseline, current, 0.05f);
CHECK((comparison.isRegression == false));
CHECK((comparison.isImprovement == false));
CHECK((comparison.mrrDelta == Approx(0.02f)));
}
SECTION("Handles first run (no baseline)") {
BenchmarkResults baseline;
baseline.mrr = 0.0f; // No previous data
baseline.queriesRun = 0;
BenchmarkResults current;
current.mrr = 0.75f;
current.recallAtK = 0.70f;
current.queriesRun = 100;
auto comparison = InternalBenchmark::compare(baseline, current, 0.05f);
// First run should not be flagged as regression
CHECK((comparison.isRegression == false));
// Could be flagged as improvement from 0
CHECK((comparison.mrrDelta == Approx(0.75f)));
}
}
// =============================================================================
// Test: BenchmarkResults summary format for CLI output
// =============================================================================
TEST_CASE("FSM Tuning Integration: BenchmarkResults summary",
"[integration][search][fsm][benchmark][summary]") {
BenchmarkResults results;
results.mrr = 0.756f;
results.recallAtK = 0.680f;
results.k = 10;
results.latency.meanMs = 42.5;
results.latency.p95Ms = 85.3;
results.queriesRun = 100;
results.queriesSucceeded = 98;
results.queriesFailed = 2;
results.totalTime = std::chrono::milliseconds(4500);
results.tuningState = "LARGE_CODE";
auto summary = results.summary();
// Verify summary contains key metrics
CHECK_THAT(summary, ContainsSubstring("MRR"));
CHECK_THAT(summary, ContainsSubstring("0.756") || ContainsSubstring("0.76"));
CHECK_THAT(summary, ContainsSubstring("Recall"));
CHECK_THAT(summary, ContainsSubstring("queries"));
}
// =============================================================================
// Test: End-to-end FSM tuning flow with real daemon
// =============================================================================
TEST_CASE("FSM Tuning Integration: End-to-end tuning validation",
"[integration][search][fsm][e2e]") {
SKIP_ON_WINDOWS_DAEMON();
std::unique_ptr<FSMTuningIntegrationFixture> fixture;
std::string fixtureError;
for (int attempt = 0; attempt < 3; ++attempt) {
try {
fixture = std::make_unique<FSMTuningIntegrationFixture>();
break;
} catch (const std::exception& e) {
fixtureError = e.what();
std::this_thread::sleep_for(300ms);
}
}
if (!fixture) {
SKIP("Skipping e2e tuning validation due to daemon startup instability: " + fixtureError);
}
// 1. Ingest documents to create a code-heavy corpus
INFO("Ingesting test documents...");
for (int i = 0; i < 5; ++i) {
auto result = fixture->ingestFile("e2e_code_" + std::to_string(i) + ".cpp",
generateCodeDocument(3 + i));
REQUIRE(result.has_value());
}
// 2. Wait for indexing
std::this_thread::sleep_for(2s);
// 3. Execute a search to verify the tuned engine works
SearchRequest req;
req.query = "processFunction vector result";
req.searchType = "hybrid";
req.limit = 10;
req.timeout = 5s;
auto searchResult = fixture->executeSearch(req);
// Verify search completes (may return empty on mock provider)
if (searchResult) {
INFO("Search returned " << searchResult.value().results.size() << " results");
// If we have results, they should be from our test files
for (const auto& hit : searchResult.value().results) {
INFO("Result: " << hit.path << " score=" << hit.score);
}
} else {
// Mock provider may cause semantic portion to fail
INFO("Search error (expected with mock): " << searchResult.error().message);
}
}
// =============================================================================
// Test: Tuner state reason provides debugging info
// =============================================================================
TEST_CASE("FSM Tuning Integration: State reason explains selection",
"[integration][search][fsm][debug]") {
CorpusStats stats;
stats.docCount = 500;
stats.codeRatio = 0.85;
stats.proseRatio = 0.10;
stats.symbolDensity = 0.5;
stats.pathDepthAvg = 3.0;
stats.tagCoverage = 0.2;
SearchTuner tuner(stats);
// State reason should explain why this state was selected
const auto& reason = tuner.stateReason();
CHECK(!reason.empty());
// Should mention the dominant corpus characteristic
// (Implementation may vary, but should provide useful debug info)
INFO("State reason: " << reason);
}
} // namespace yams::test