yams/tests/integration/search/fsm_tuning_benchmark_integration_test.cpp
Trevon a3d065dd8d
feat(search): make topology-assisted retrieval the default (#57)
* fix(release): repair package repo publish

Arch repo metadata and R2 uploads need to be reproducible from CI, with retry hardening for transient Cloudflare failures.

* fix(daemon): stop shutdown log storm

Init waiter must treat ShuttingDown as terminal so daemon shutdown tests do not flood Meson logs while the daemon is stopping.

* refactor(cli): delegate serve to MCP binary

Keep yams serve UX while removing MCP server linkage from yams-cli. The standalone MCP binary owns stdio serving and exits on stdin EOF.

* refactor(cli): move daemon path helpers

Keep CLI path resolution out of YamsDaemon so later link cuts can separate CLI and daemon internals. Full yams_daemon removal still needs a socket-only daemon-client split.

* refactor(daemon): split embedded client

* perf(search): add lean-guided benchmark gates

Add Lean topology/search models, ingestion ablation docs, and focused regression tests for topology and compression threshold behavior.

Extract search observability, keep benchmark-only ablation controls, and make retrieval quality benchmarks default to Simeon while preserving explicit ONNX opt-in.

* refactor(config): move daemon policies

* refactor(daemon): extract plugin runtime

* refactor(daemon): untangle service coordinators

Untangle CLI/MCP from daemon service coordinators and the daemon static lib.

- MetadataRepository uses a daemon-neutral tree-diff callback. The
  GraphComponent wrapper moves to src/daemon/components/metadata_graph_bridge.cpp,
  owned by the daemon library.
- WriteCoordinator moves to a dedicated build-only yams_write_coordinator
  static lib. Drop ResourceGovernor coupling inside the writer loop; batch
  sizing still respects config.maxBatchSize.
- AppContext gains enqueuePostIngest callback. ServiceManager wires it to
  its own enqueuePostIngest; app services no longer call ServiceManager
  directly.
- Embedding repair uses BulkIngestLease/BeginBulkIngestCallback instead of
  concrete VectorIndexCoordinator::BulkScope. Daemon dispatchers provide a
  lease factory backed by the real coordinator.
- CLI daemon_command uses config helpers instead of YamsDaemon::getXDGRuntimeDir.
- Remove explicit dependency('yams_daemon') from src/cli and tools/yams-mcp.
- Declare missing yams_extraction/yams_ingest deps on yams_app_services so
  app services link cleanly without daemon.

Validated:
  meson compile -C build/release -j4 yams-daemon yams-cli yams-mcp-server
  ninja -C build/release -t commands tools/yams-cli/yams-cli tools/yams-mcp/yams-mcp-server | grep -c libyams_daemon.a -> 0
  yams-cli --version, yams-mcp-server init, yams serve --help smoke tests

Size drop on macOS release: yams-cli 41M -> 32M, yams-mcp-server 37M -> 22M.

* fix(cli): keep stdin add local fallback

No embedded daemon now. When yams add - buffers stdin and daemon IPC is missing, store via local app services instead of failing socket_missing.

Validated:
  meson compile -C build/debug -j4 yams-cli catch2_cli_commands
  MESON_BUILD_ROOT=/Users/trevon/work/tools/yams/build/debug build/debug/tests/catch2_cli_commands 'AddCommand - reads from piped stdin and stores content'
  MESON_BUILD_ROOT=/Users/trevon/work/tools/yams/build/debug build/debug/tests/catch2_cli_commands
  meson compile -C build/release -j4 yams-cli yams-mcp-server
  meson compile -C build/asan-api -j2 yams-cli catch2_cli_commands
  MESON_BUILD_ROOT=/Users/trevon/work/tools/yams/build/asan-api ASAN_OPTIONS=halt_on_error=1:abort_on_error=1:print_summary=1 build/asan-api/tests/catch2_cli_commands 'AddCommand - reads from piped stdin and stores content'
  ninja -C build/release -t commands tools/yams-cli/yams-cli tools/yams-mcp/yams-mcp-server | grep libyams_daemon.a -> no output

* feat(bench): add xplan multi-arm experiment harness

Unified plan runner, ablation axes, REPORT/ablation docs, and
KPI snapshot so optimization loops stop living in shell matrices.

* feat(formal): expand topology/search Lean contracts

Boundary models for search provenance, construction gates,
and cluster/route objectives so Lean guides correctness while
xplan ranks performance levers.

* feat(search,topology): construction purity + graph expansion path

Raise topology build defaults (min edge 0.25, max component 64) and
split oversized CC pieces so hybrid assist is quality-positive on
scifact. Add GraphNeighbors expansion with medoid-anchor fallback,
structured helpers instead of nested session logic, and BEIR-default
xplan plans. Search engine extraction (fanout/classic pipeline) rides
along as the integration surface for routing.

* feat(topology): add KMeans engine and SGC identity contracts

Register kmeans in the factory with deterministic spherical k-means
over embeddings, and enforce SGC document-identity invariants in
debug builds with matching unit coverage.

* feat(daemon): topology readiness gates and selectable reranker

Drive topologyReady from published epoch/freshness/rebuild/dirty
ingest state (not just KG drain), surface those fields in search
stats, and wire onnx|simeon|auto|none cross-reranker selection.

* chore(search): drop meta_path tuner knobs

Meta-path routing is gone; remove dead TunedParams fields and the
ConfigResolver test for [search.meta_path].

* feat(bench): add topology_purity_validate plan

Repeats=3 validation matrix for construction-purity winners vs
topo_off so MRR deltas can clear run-to-run noise.

* chore: format add_command and config include order

Mechanical clang-format / include reorder only; no behavior change.

* feat(bench): decision-grade xplan loop for search engine

Document search-engine measurement in AGENTS; harden xplan with
repeats=3 plans, quality×latency primaries, mechanism metrics from
debug.jsonl, multi-rep log promotion, and archive superseded topology
plans.

* feat(graph): denser product semantic_neighbor topK

Raise default YAMS_GRAPH_SEMANTIC_TOPK fallback from 4 to 8 so product
graphs leave fewer degree-0 docs for GraphNeighbors expansion.

* feat(search): extract topology assist and cross-rerank seams

Pull topology assist into search_topology_stage (seed merge, skip
reasons, optional vector seed probe default 0). Extract cross_rerank
compute from search_engine. Wire probe via typed config/ConfigResolver.

* feat(bench): product-path search engine ablation plans

Add topology-off component, vector-weight, and tiered-execution xplan
plans (repeats=3) and wire them into the AGENTS measurement loop table.

* fix(ci): repair pre-push fallbacks

* feat(bench): lock-free mid-run builds + multi-corpus gates

xplan skips meson rebuild when the quality binary exists (flock +
YAMS_BENCH_FORCE_BUILD override) to stop multi-arm link races. Add
graph_vector weight and nfcorpus transfer-gate plans; document in AGENTS.

* fix(daemon): keep SearchEngine alive via shared_ptr cache

getCachedEngine previously published a raw pointer that metrics polling
used through rebuild, racing destructor of SearchEngine::Impl (TSAN).
Store and return a shared_ptr snapshot so diagnostic readers pin the
engine for the duration of getConfig/health checks.

Also adds the cautious vectorWeight 0.30→0.20 multi-corpus xplan plan.

* feat(search): route vectors through cached topology

Build and reuse validated topology snapshots, rank routed members by query similarity, and narrow the vector leg to the accepted document set with explicit work diagnostics.

Remove the retired HDBSCAN engine and vendor dependency, preserve typed topology policy through search tuning, and cover the runtime/configuration contracts.

* feat(bench): consolidate search decision plans

Enforce deterministic experiment identity and declared baselines, expose routed/global vector work metrics, and register the focused benchmark contracts.

Promote the compact repeats=3 routing-budget gate, archive superseded topology mechanism screens, and add multi-corpus product calibration plans.

* feat(search): refine topology vector routing

* fix(search): propagate daemon readiness

* feat(bench): measure generalized memory health

* fix(cli): honor in-process fallbacks

* ci: harden smolvm debug builds

* perf(search): route topology through filtered ANN

* feat(bench): measure read-write pressure

* perf(metadata): index symbol lookups on read pool

* fix(daemon): isolate startup reads and maintenance

* feat(bench): replay isolated generalized corpora

* feat(search): add evidence routing pipeline

Replace the blocking smolvm pre-push path with host ASan/TSan validation, harden sanitizer-sensitive smoke tests, and add recovery cleanup for malformed SQLite WAL sidecars.

* refactor(search): unify topology evidence path

Remove classic and sidecar fusion lanes so topology augments the recall-preserving ANN and lexical candidate pool through one product path.

* refactor(bench): prune superseded search lanes

Measure the topology-plus-ANN product path with stable experiment identity and remove obsolete sidecar and tiered controls.

* docs(search): retire stale experiment notes

Keep the benchmark handoff focused on the topology-plus-ANN product lane and its active decision plans.

* refactor(search): prune dead search surfaces

Remove unused fusion, reranker, policy, and router abstractions.
Bound topology materialization and keep document trace payloads opt-in.

* fix(cli): clear build warnings

* fix(xplan): reject stale run identity

* refactor(search)!: remove legacy query stack

Replace the unused AST/ranker/post-processor surface with focused facet aggregation used by product search.

BREAKING CHANGE: removes query parser, result ranker, search filter, and SearchResults container APIs from yams_search.

* refactor(search): type value stage traces

Remove synthetic ComponentResult markers for embedding and concept stages. Preserve failure and timeout states instead of overwriting them with fake successful results.

* refactor(search): isolate benchmark env overlays

Move test-only search environment parsing behind an injectable internal adapter. Keep all existing overlay keys and tuner pin semantics while restoring SearchEngineBuilder to composition.

* refactor(search): unify topology routing options

Snapshot topology policy once at the assist boundary and pass it through routing sessions and diagnostics. Remove repeated scalar copying without changing routing behavior or metric keys.

* fix(search): unify tuner config ownership

Use one complete config-to-params overlay for runtime seeding and no-tuner policy resolution. Reseed attached tuners on setConfig so reported and executed configuration cannot diverge.

* fix(search): snapshot tuner state per query

Read tuned config, params, and state under one lock before routing. Add a TSAN correlation test so adaptive observation cannot produce a torn logical query snapshot.

* fix(search): enforce topology routing contract

Generated configs must preserve hybrid-assist shadow routing, while daemon-backed search must abstain when published topology artifacts are stale. Keep standalone validation and global ANN recall intact.

* fix(daemon): let SQLite reclaim its file

Directly resizing a live database from sampled page counts can truncate pages allocated by concurrent writers. VACUUM must remain the only file-reclamation mechanism.

* fix(metadata): tolerate missing symbol FTS

Readiness lookups can race an unavailable optional FTS migration. Retain the indexed fast path, but retry against the base table when prepare or cached-statement execution proves the capability absent.

* build(fuzz): drop removed query parser target

The optional AFL build still referenced the deleted legacy query parser API and could no longer compile.

* fix(bench): include writes in drain oracle

A zero post-ingest queue is not durable drain while coordinated writes remain queued or in flight.

* fix(daemon): vacuum padded WAL databases safely
2026-07-13 20:26:43 -04:00

587 lines
22 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 <chrono>
#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]") {
// This test validates SearchTuner state selection logic
// without requiring daemon - uses CorpusStats directly
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);
CHECK(tuner.getRrfK() == 20);
// Graph-aware tuning may shift some rank mass from text/vector to KG.
if (stats.hasKnowledgeGraph()) {
CHECK(tuner.getParams().weights.text.value < 0.45f);
CHECK(tuner.getParams().weights.text.value > 0.39f);
CHECK(tuner.getParams().weights.kg.value > 0.05f);
} else {
CHECK(tuner.getParams().weights.text.value == Approx(0.45f));
}
CHECK(tuner.getParams().weights.pathTree.value == Approx(0.15f).epsilon(0.01));
}
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);
CHECK(tuner.getRrfK() == 25);
CHECK(tuner.getParams().weights.vector.value == Approx(0.40f));
CHECK(tuner.getParams().weights.pathTree.value == Approx(0.00f));
}
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);
// Scientific corpus: prose-dominant, flat structure, no tags. The profile
// intentionally reuses the product MIXED_PRECISION text/vector package and
// zeros structure-only sources instead of applying the old aggressive
// scientific overrides.
CHECK(tuner.currentState() == TuningState::SCIENTIFIC);
CHECK((tuner.getRrfK() == 45));
CHECK((tuner.getParams().weights.text.value == Approx(0.61538f).margin(0.0001f)));
CHECK((tuner.getParams().weights.simeonText.value == Approx(0.00f).margin(0.0001f)));
CHECK((tuner.getParams().weights.vector.value == Approx(0.38462f).margin(0.0001f)));
CHECK((tuner.getParams().weights.kg.value == Approx(0.00f).margin(0.0001f)));
CHECK((tuner.getParams().weights.tag.value == Approx(0.00f).margin(0.0001f)));
}
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));
CHECK((tuner.getRrfK() == 45));
}
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);
// Very small corpus should use MINIMAL state. Dead-source gates renormalize
// the default MINIMAL weights after disabling unavailable structure signals.
CHECK((tuner.currentState() == TuningState::MINIMAL));
CHECK((tuner.getRrfK() == 15));
CHECK((tuner.getParams().weights.text.value == Approx(0.56701f).margin(0.0001f)));
}
}
// =============================================================================
// 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.vectorWeight == tuner.getParams().weights.vector.value));
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