* 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
578 lines
22 KiB
C++
578 lines
22 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
// Copyright 2026 YAMS Contributors
|
||
|
||
#include <catch2/catch_approx.hpp>
|
||
#include <catch2/catch_test_macros.hpp>
|
||
|
||
#include <yams/daemon/components/TopologyTuner.h>
|
||
|
||
#include <chrono>
|
||
#include <filesystem>
|
||
#include <fstream>
|
||
|
||
using Catch::Approx;
|
||
using yams::daemon::computeIntrinsicReward;
|
||
using yams::daemon::defaultArmGrid;
|
||
using yams::daemon::IntrinsicRewardWeights;
|
||
using yams::daemon::TopologyArm;
|
||
using yams::daemon::TopologyTuner;
|
||
using yams::daemon::TopologyTunerConfig;
|
||
using RebuildStats = yams::daemon::TopologyManager::RebuildStats;
|
||
|
||
namespace {
|
||
RebuildStats makeStats(double singleton, double giant, double gini, double intra,
|
||
std::uint64_t docs = 1000) {
|
||
RebuildStats s;
|
||
s.skipped = false;
|
||
s.documentsProcessed = docs;
|
||
s.singletonRatio = singleton;
|
||
s.giantClusterRatio = giant;
|
||
s.clusterSizeGini = gini;
|
||
s.avgIntraEdgeWeight = intra;
|
||
return s;
|
||
}
|
||
} // namespace
|
||
|
||
TEST_CASE("TopologyTuner: defaultArmGrid covers supported topology engines",
|
||
"[unit][daemon][topology_tuner][catch2]") {
|
||
auto arms = defaultArmGrid(5183);
|
||
|
||
REQUIRE(arms.size() == 2);
|
||
CHECK(arms[0].id == "connected");
|
||
CHECK(arms[0].engine == "connected");
|
||
CHECK(arms[1].id == "louvain");
|
||
CHECK(arms[1].engine == "louvain");
|
||
|
||
// No two arms should share the same id (deduplication invariant).
|
||
for (std::size_t i = 0; i < arms.size(); ++i) {
|
||
for (std::size_t j = i + 1; j < arms.size(); ++j) {
|
||
CHECK(arms[i].id != arms[j].id);
|
||
}
|
||
}
|
||
}
|
||
|
||
TEST_CASE("TopologyTuner: computeIntrinsicReward favors balanced clusterings",
|
||
"[unit][daemon][topology_tuner][catch2]") {
|
||
IntrinsicRewardWeights w;
|
||
|
||
SECTION("singleton-heavy clustering yields a low reward") {
|
||
auto stats = makeStats(/*singleton=*/0.9, /*giant=*/0.0, /*gini=*/0.4, /*intra=*/0.3);
|
||
const double r = computeIntrinsicReward(stats, w);
|
||
CHECK(r < 0.7);
|
||
}
|
||
|
||
SECTION("giant-cluster collapse yields a low reward") {
|
||
auto stats = makeStats(/*singleton=*/0.0, /*giant=*/0.9, /*gini=*/0.4, /*intra=*/0.3);
|
||
const double r = computeIntrinsicReward(stats, w);
|
||
CHECK(r < 0.7);
|
||
}
|
||
|
||
SECTION("balanced clustering yields a high reward") {
|
||
auto stats = makeStats(/*singleton=*/0.05, /*giant=*/0.10, /*gini=*/0.4, /*intra=*/0.5);
|
||
const double r = computeIntrinsicReward(stats, w);
|
||
CHECK(r > 0.85);
|
||
}
|
||
|
||
SECTION("skipped or empty rebuilds yield zero") {
|
||
RebuildStats skipped;
|
||
skipped.skipped = true;
|
||
skipped.documentsProcessed = 1000;
|
||
CHECK(computeIntrinsicReward(skipped, w) == Approx(0.0));
|
||
|
||
RebuildStats empty;
|
||
empty.skipped = false;
|
||
empty.documentsProcessed = 0;
|
||
CHECK(computeIntrinsicReward(empty, w) == Approx(0.0));
|
||
}
|
||
|
||
SECTION("reward stays clamped to [0, 1] under extreme inputs") {
|
||
auto stats = makeStats(/*singleton=*/2.0, /*giant=*/2.0, /*gini=*/-5.0, /*intra=*/100.0);
|
||
const double r = computeIntrinsicReward(stats, w);
|
||
CHECK(r >= 0.0);
|
||
CHECK(r <= 1.0);
|
||
}
|
||
|
||
SECTION("quadratic penalty: giant=1.0 collapse scores well below half") {
|
||
// With the plan-G quadratic formula (α=β=0.7), giant=1.0 removes 0.7
|
||
// from the baseline 1.0, leaving ~0.3 plus a small intra bonus. This
|
||
// is the pathological all-in-one-cluster case from Phase G bench.
|
||
auto stats = makeStats(/*singleton=*/0.0, /*giant=*/1.0, /*gini=*/0.0, /*intra=*/0.5);
|
||
const double r = computeIntrinsicReward(stats, w);
|
||
CHECK(r < 0.45);
|
||
}
|
||
|
||
SECTION("quadratic penalty: moderate imbalance (~0.2) is nearly free") {
|
||
// Same inputs under linear would be 1 - 0.14 = 0.86; under quadratic
|
||
// are 1 - 0.028 = 0.97. The bandit should tolerate normal variance.
|
||
auto stats = makeStats(/*singleton=*/0.2, /*giant=*/0.2, /*gini=*/0.4, /*intra=*/0.5);
|
||
const double r = computeIntrinsicReward(stats, w);
|
||
CHECK(r > 0.9);
|
||
}
|
||
}
|
||
|
||
TEST_CASE("TopologyTuner: cooldown gate blocks back-to-back pulls",
|
||
"[unit][daemon][topology_tuner][catch2]") {
|
||
TopologyTunerConfig cfg;
|
||
cfg.enabled = true;
|
||
cfg.cooldown = std::chrono::minutes(10);
|
||
cfg.docCountDelta = 100;
|
||
|
||
TopologyTuner tuner(cfg);
|
||
tuner.setArms(defaultArmGrid(1000));
|
||
|
||
using clock = std::chrono::steady_clock;
|
||
const auto t0 = clock::now();
|
||
|
||
SECTION("just-past-rebuild blocks") {
|
||
const auto justAfter = t0 + std::chrono::seconds(30);
|
||
CHECK_FALSE(tuner.canPullArm(justAfter, t0, std::chrono::milliseconds(1500),
|
||
/*corpusDocCount=*/1000, /*lastPullDocCount=*/900));
|
||
}
|
||
|
||
SECTION("after cooldown but no new docs blocks") {
|
||
const auto afterCooldown = t0 + std::chrono::minutes(11);
|
||
CHECK_FALSE(tuner.canPullArm(afterCooldown, t0, std::chrono::milliseconds(500),
|
||
/*corpusDocCount=*/950, /*lastPullDocCount=*/900));
|
||
}
|
||
|
||
SECTION("after cooldown and enough new docs unblocks") {
|
||
const auto afterCooldown = t0 + std::chrono::minutes(11);
|
||
CHECK(tuner.canPullArm(afterCooldown, t0, std::chrono::milliseconds(500),
|
||
/*corpusDocCount=*/1100, /*lastPullDocCount=*/900));
|
||
}
|
||
|
||
SECTION("cooldown scales with last rebuild duration") {
|
||
// 60s rebuild → 600s scaled cooldown; 11min wall is enough but
|
||
// a 12min rebuild → scaled cooldown 120 min, blocks at 11 min.
|
||
const auto eleven = t0 + std::chrono::minutes(11);
|
||
CHECK(tuner.canPullArm(eleven, t0, std::chrono::seconds(60),
|
||
/*corpus=*/1100, /*last=*/900));
|
||
CHECK_FALSE(tuner.canPullArm(eleven, t0, std::chrono::minutes(12),
|
||
/*corpus=*/1100, /*last=*/900));
|
||
}
|
||
|
||
SECTION("disabled tuner never pulls") {
|
||
TopologyTunerConfig off;
|
||
off.enabled = false;
|
||
TopologyTuner offTuner(off);
|
||
offTuner.setArms(defaultArmGrid(1000));
|
||
const auto wayLater = t0 + std::chrono::hours(24);
|
||
CHECK_FALSE(offTuner.canPullArm(wayLater, t0, std::chrono::milliseconds(0),
|
||
/*corpus=*/100000, /*last=*/0));
|
||
}
|
||
}
|
||
|
||
TEST_CASE("TopologyTuner: selectArm + observeRebuildStats round-trip via UCB1",
|
||
"[unit][daemon][topology_tuner][catch2]") {
|
||
TopologyTunerConfig cfg;
|
||
cfg.enabled = true;
|
||
TopologyTuner tuner(cfg);
|
||
tuner.setArms(defaultArmGrid(1000));
|
||
|
||
// Pull every arm once with a constant reward so UCB1 is fully warmed.
|
||
auto good = makeStats(0.05, 0.10, 0.4, 0.5);
|
||
for (std::size_t i = 0; i < tuner.arms().size(); ++i) {
|
||
auto picked = tuner.selectArm();
|
||
REQUIRE(picked.has_value());
|
||
tuner.observeRebuildStats(picked->id, good);
|
||
}
|
||
|
||
// After every arm has at least one observation, bestArmId should be
|
||
// populated; with identical rewards it just picks the first by id order
|
||
// — the contract is "non-empty after at least one pull", not a specific id.
|
||
auto best = tuner.bestArmId();
|
||
CHECK(best.has_value());
|
||
}
|
||
|
||
TEST_CASE("TopologyTuner: rewardMode routes reward computation correctly",
|
||
"[unit][daemon][topology_tuner][catch2][tda]") {
|
||
// Two synthetic persistence signals: low (0.10) and high (0.80).
|
||
// Scale = 1.0 so normalization clamps to [0, 1] directly.
|
||
const auto stats = makeStats(0.1, 0.1, 0.4, 0.5); // high geometric reward
|
||
|
||
SECTION("Geometric mode ignores persistence") {
|
||
TopologyTunerConfig cfg;
|
||
cfg.enabled = true;
|
||
cfg.rewardMode = yams::daemon::TunerRewardMode::Geometric;
|
||
TopologyTuner tuner(cfg);
|
||
tuner.setArms(defaultArmGrid(1000));
|
||
auto arm = tuner.selectArm();
|
||
REQUIRE(arm.has_value());
|
||
// Persistence signal 0.8 vs 0.0 should NOT affect MAB state in Geometric mode.
|
||
tuner.observeRebuildStatsWithPersistence(arm->id, stats, /*persistence=*/0.8,
|
||
/*scale=*/1.0);
|
||
auto best1 = tuner.bestArmId();
|
||
REQUIRE(best1.has_value());
|
||
// Re-init and observe the same arm with persistence=0.0; reward should be same.
|
||
TopologyTuner tuner2(cfg);
|
||
tuner2.setArms(defaultArmGrid(1000));
|
||
auto arm2 = tuner2.selectArm();
|
||
REQUIRE(arm2.has_value());
|
||
tuner2.observeRebuildStatsWithPersistence(arm2->id, stats, /*persistence=*/0.0,
|
||
/*scale=*/1.0);
|
||
// Both bestArmId calls return non-empty; MAB ranks arms the same way
|
||
// because geometric reward was identical. Proof-by-contract: the
|
||
// underlying TunerMAB recorded rewards that, under Geometric mode,
|
||
// are equal regardless of the persistence argument.
|
||
auto best2 = tuner2.bestArmId();
|
||
REQUIRE(best2.has_value());
|
||
CHECK(*best1 == *best2);
|
||
}
|
||
|
||
SECTION("Persistence mode uses persistence signal as reward") {
|
||
TopologyTunerConfig cfg;
|
||
cfg.enabled = true;
|
||
cfg.rewardMode = yams::daemon::TunerRewardMode::Persistence;
|
||
TopologyTuner tuner(cfg);
|
||
// Two-arm grid for clear ranking.
|
||
tuner.setArms(std::vector<TopologyArm>{
|
||
TopologyArm{"arm_a", "connected"},
|
||
TopologyArm{"arm_b", "louvain"},
|
||
});
|
||
// Pull arm_a with high persistence, arm_b with low.
|
||
auto armA = tuner.selectArm();
|
||
REQUIRE(armA.has_value());
|
||
tuner.observeRebuildStatsWithPersistence(armA->id, stats, 0.9, 1.0);
|
||
auto armB = tuner.selectArm();
|
||
REQUIRE(armB.has_value());
|
||
tuner.observeRebuildStatsWithPersistence(armB->id, stats, 0.1, 1.0);
|
||
// Pull 8 more times to cement rankings.
|
||
for (int i = 0; i < 8; ++i) {
|
||
auto picked = tuner.selectArm();
|
||
REQUIRE(picked.has_value());
|
||
const double signal = (picked->id == "arm_a") ? 0.9 : 0.1;
|
||
tuner.observeRebuildStatsWithPersistence(picked->id, stats, signal, 1.0);
|
||
}
|
||
auto best = tuner.bestArmId();
|
||
REQUIRE(best.has_value());
|
||
CHECK(*best == "arm_a");
|
||
}
|
||
|
||
SECTION("Hybrid mode blends geometric + persistence") {
|
||
TopologyTunerConfig cfg;
|
||
cfg.enabled = true;
|
||
cfg.rewardMode = yams::daemon::TunerRewardMode::Hybrid;
|
||
TopologyTuner tuner(cfg);
|
||
tuner.setArms(std::vector<TopologyArm>{
|
||
TopologyArm{"arm_a", "connected"},
|
||
TopologyArm{"arm_b", "louvain"},
|
||
});
|
||
// arm_a: low geometric, high persistence
|
||
// arm_b: high geometric, low persistence
|
||
// With 0.5/0.5 blend and stats yielding ~1.0 geometric, arm_b should win
|
||
// but with a margin smaller than pure geometric.
|
||
const auto lowGeom = makeStats(0.5, 0.5, 0.7, 0.2); // low geometric
|
||
const auto highGeom = makeStats(0.05, 0.1, 0.4, 0.6); // high geometric
|
||
for (int i = 0; i < 10; ++i) {
|
||
auto picked = tuner.selectArm();
|
||
REQUIRE(picked.has_value());
|
||
if (picked->id == "arm_a") {
|
||
tuner.observeRebuildStatsWithPersistence(picked->id, lowGeom, 0.9, 1.0);
|
||
} else {
|
||
tuner.observeRebuildStatsWithPersistence(picked->id, highGeom, 0.1, 1.0);
|
||
}
|
||
}
|
||
auto best = tuner.bestArmId();
|
||
REQUIRE(best.has_value());
|
||
// With 0.5·highGeom(~0.9) + 0.5·0.1 = 0.5 for arm_b
|
||
// vs 0.5·lowGeom(~0.4) + 0.5·0.9 = 0.65 for arm_a
|
||
// So arm_a should win in Hybrid mode despite losing in pure Geometric.
|
||
CHECK(*best == "arm_a");
|
||
}
|
||
}
|
||
|
||
TEST_CASE("TopologyTuner: disabled tuner returns nullopt from selectArm",
|
||
"[unit][daemon][topology_tuner][catch2]") {
|
||
TopologyTunerConfig cfg;
|
||
cfg.enabled = false;
|
||
TopologyTuner tuner(cfg);
|
||
tuner.setArms(defaultArmGrid(1000));
|
||
CHECK_FALSE(tuner.selectArm().has_value());
|
||
}
|
||
|
||
TEST_CASE("TopologyTuner: saveState / loadState round-trip preserves arm rewards",
|
||
"[unit][daemon][topology_tuner][catch2]") {
|
||
namespace fs = std::filesystem;
|
||
auto tmpDir = fs::temp_directory_path() /
|
||
("yams_topology_tuner_state_" +
|
||
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||
fs::create_directories(tmpDir);
|
||
const auto statePath = tmpDir / "topology_tuner_state.json";
|
||
|
||
// Producer: tune an arm with a known reward, save.
|
||
{
|
||
TopologyTunerConfig cfg;
|
||
cfg.enabled = true;
|
||
TopologyTuner tuner(cfg);
|
||
tuner.setArms(defaultArmGrid(1000));
|
||
auto arm = tuner.selectArm();
|
||
REQUIRE(arm.has_value());
|
||
auto good = makeStats(0.05, 0.10, 0.4, 0.5);
|
||
tuner.observeRebuildStats(arm->id, good);
|
||
auto saved = tuner.saveState(statePath);
|
||
REQUIRE(saved.has_value());
|
||
REQUIRE(fs::exists(statePath));
|
||
}
|
||
|
||
// Consumer: construct a new tuner, load state, verify bestArmId is
|
||
// populated (non-empty) — the loaded pulls count carried over.
|
||
{
|
||
TopologyTunerConfig cfg;
|
||
cfg.enabled = true;
|
||
TopologyTuner tuner(cfg);
|
||
tuner.setArms(defaultArmGrid(1000));
|
||
auto loaded = tuner.loadState(statePath);
|
||
REQUIRE(loaded.has_value());
|
||
auto best = tuner.bestArmId();
|
||
CHECK(best.has_value());
|
||
}
|
||
|
||
std::error_code ec;
|
||
fs::remove_all(tmpDir, ec);
|
||
}
|
||
|
||
TEST_CASE("TopologyTuner: loadState on missing path returns FileNotFound",
|
||
"[unit][daemon][topology_tuner][catch2]") {
|
||
namespace fs = std::filesystem;
|
||
const auto missingPath = fs::temp_directory_path() / "yams_topology_tuner_does_not_exist.json";
|
||
std::error_code ec;
|
||
fs::remove(missingPath, ec);
|
||
|
||
TopologyTunerConfig cfg;
|
||
cfg.enabled = true;
|
||
TopologyTuner tuner(cfg);
|
||
tuner.setArms(defaultArmGrid(1000));
|
||
auto result = tuner.loadState(missingPath);
|
||
CHECK_FALSE(result.has_value());
|
||
}
|
||
|
||
TEST_CASE("TopologyTuner: restored state drops retired engine arms",
|
||
"[unit][daemon][topology_tuner][catch2]") {
|
||
TopologyTunerConfig cfg;
|
||
cfg.enabled = true;
|
||
TopologyTuner tuner(cfg);
|
||
tuner.setArms(defaultArmGrid(1000));
|
||
|
||
auto state = tuner.toJson();
|
||
auto& mabArms = state["mab"]["arms"];
|
||
REQUIRE(mabArms.is_array());
|
||
REQUIRE(mabArms.size() == 2);
|
||
mabArms[0]["pulls"] = 1;
|
||
mabArms[0]["reward_sum"] = 0.5;
|
||
mabArms.push_back({
|
||
{"id", "retired_engine_arm"},
|
||
{"value", 8.0},
|
||
{"pulls", 100},
|
||
{"reward_sum", 100.0},
|
||
});
|
||
|
||
REQUIRE(tuner.fromJson(state));
|
||
const auto best = tuner.bestArmId();
|
||
REQUIRE(best.has_value());
|
||
CHECK(*best == "connected");
|
||
REQUIRE(tuner.arms().size() == 2);
|
||
CHECK(tuner.arms()[0].engine == "connected");
|
||
CHECK(tuner.arms()[1].engine == "louvain");
|
||
}
|
||
|
||
TEST_CASE("TopologyTuner: rebuildArmGridForCorpusSize is no-op when ids match",
|
||
"[unit][daemon][topology_tuner][catch2]") {
|
||
TopologyTunerConfig cfg;
|
||
cfg.enabled = true;
|
||
TopologyTuner tuner(cfg);
|
||
tuner.setArms(defaultArmGrid(5000));
|
||
const auto initialCount = tuner.arms().size();
|
||
|
||
// Same corpus size → no-op
|
||
CHECK_FALSE(tuner.rebuildArmGridForCorpusSize(5000));
|
||
CHECK(tuner.arms().size() == initialCount);
|
||
|
||
// Supported engine arms are corpus-size independent.
|
||
CHECK_FALSE(tuner.rebuildArmGridForCorpusSize(50000));
|
||
CHECK(tuner.arms().size() == initialCount);
|
||
}
|
||
|
||
TEST_CASE("TopologyTuner: UCB1 explores arms then converges to highest reward",
|
||
"[unit][daemon][topology_tuner][catch2]") {
|
||
// Build a tiny 3-arm bandit with deterministic synthetic rewards. Goal:
|
||
// verify that after a burn-in of pulls, bestArmId matches the arm that
|
||
// received consistently higher reward observations. This proves the
|
||
// bandit isn't degenerate (always-first-arm) when reward signals differ.
|
||
TopologyTunerConfig cfg;
|
||
cfg.enabled = true;
|
||
TopologyTuner tuner(cfg);
|
||
|
||
std::vector<TopologyArm> arms = {
|
||
TopologyArm{"arm_low", "connected"},
|
||
TopologyArm{"arm_mid", "louvain"},
|
||
TopologyArm{"arm_high", "kmeans"},
|
||
};
|
||
tuner.setArms(arms);
|
||
|
||
// Synthetic rewards per arm: arm_high gives the best clusterings.
|
||
auto statsForArm = [](const std::string& id) {
|
||
if (id == "arm_high") {
|
||
return makeStats(/*singleton=*/0.05, /*giant=*/0.10, /*gini=*/0.4, /*intra=*/0.7);
|
||
}
|
||
if (id == "arm_mid") {
|
||
return makeStats(/*singleton=*/0.20, /*giant=*/0.30, /*gini=*/0.5, /*intra=*/0.4);
|
||
}
|
||
return makeStats(/*singleton=*/0.50, /*giant=*/0.60, /*gini=*/0.7, /*intra=*/0.2);
|
||
};
|
||
|
||
// Pull 30 times — UCB1 should explore each arm a few times, then exploit
|
||
// arm_high. Without sufficient pulls, exploration dominates.
|
||
for (int i = 0; i < 30; ++i) {
|
||
auto picked = tuner.selectArm();
|
||
REQUIRE(picked.has_value());
|
||
tuner.observeRebuildStats(picked->id, statsForArm(picked->id));
|
||
}
|
||
|
||
auto best = tuner.bestArmId();
|
||
REQUIRE(best.has_value());
|
||
CHECK(*best == "arm_high");
|
||
}
|
||
|
||
TEST_CASE("TopologyTuner: shadow-divergence detects intrinsic-up + extrinsic-down trend",
|
||
"[unit][daemon][topology_tuner][catch2]") {
|
||
TopologyTunerConfig cfg;
|
||
cfg.enabled = true;
|
||
TopologyTuner tuner(cfg);
|
||
tuner.setArms(defaultArmGrid(1000));
|
||
|
||
// Pre-condition: no warnings yet.
|
||
CHECK(tuner.divergenceWarningCount() == 0u);
|
||
|
||
// Three pulls each with monotonically improving intrinsic reward (lower
|
||
// singleton/giant) but monotonically worsening synthetic extrinsic signal.
|
||
auto pullAndShadow = [&](double singleton, double giant, double intra, double extrinsicSignal) {
|
||
auto picked = tuner.selectArm();
|
||
REQUIRE(picked.has_value());
|
||
auto stats = makeStats(singleton, giant, /*gini=*/0.4, intra);
|
||
tuner.observeRebuildStats(picked->id, stats);
|
||
tuner.observeShadowExtrinsic(extrinsicSignal);
|
||
};
|
||
|
||
// Intrinsic improves: 0.30 → 0.35 → 0.40
|
||
// Extrinsic drops: 0.55 → 0.52 → 0.45 (>0.02 cumulative drop)
|
||
pullAndShadow(/*sing=*/0.5, /*giant=*/0.5, /*intra=*/0.3, /*ext=*/0.55);
|
||
pullAndShadow(/*sing=*/0.4, /*giant=*/0.4, /*intra=*/0.4, /*ext=*/0.52);
|
||
pullAndShadow(/*sing=*/0.3, /*giant=*/0.3, /*intra=*/0.5, /*ext=*/0.45);
|
||
|
||
CHECK(tuner.divergenceWarningCount() >= 1u);
|
||
}
|
||
|
||
TEST_CASE("TopologyTuner: shadow-divergence does NOT trigger when both signals agree",
|
||
"[unit][daemon][topology_tuner][catch2]") {
|
||
TopologyTunerConfig cfg;
|
||
cfg.enabled = true;
|
||
TopologyTuner tuner(cfg);
|
||
tuner.setArms(defaultArmGrid(1000));
|
||
|
||
auto pullAndShadow = [&](double singleton, double giant, double intra, double extrinsicSignal) {
|
||
auto picked = tuner.selectArm();
|
||
REQUIRE(picked.has_value());
|
||
auto stats = makeStats(singleton, giant, /*gini=*/0.4, intra);
|
||
tuner.observeRebuildStats(picked->id, stats);
|
||
tuner.observeShadowExtrinsic(extrinsicSignal);
|
||
};
|
||
|
||
// Both signals improve in lockstep — no divergence.
|
||
pullAndShadow(0.5, 0.5, 0.3, 0.40);
|
||
pullAndShadow(0.4, 0.4, 0.4, 0.50);
|
||
pullAndShadow(0.3, 0.3, 0.5, 0.55);
|
||
|
||
CHECK(tuner.divergenceWarningCount() == 0u);
|
||
}
|
||
|
||
TEST_CASE("TopologyTuner: cross-restart persistence preserves UCB1 convergence",
|
||
"[unit][daemon][topology_tuner][catch2]") {
|
||
// Simulates the daemon-restart pattern: tuner A pulls + observes some
|
||
// arms, persists; tuner B loads the same state and continues pulling.
|
||
// By the end, the bandit should converge on the synthetic best arm
|
||
// even though no single tuner instance saw all 30 pulls.
|
||
namespace fs = std::filesystem;
|
||
auto tmpDir = fs::temp_directory_path() /
|
||
("yams_topology_tuner_xrestart_" +
|
||
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||
fs::create_directories(tmpDir);
|
||
const auto statePath = tmpDir / "tuner_state.json";
|
||
|
||
std::vector<TopologyArm> arms = {
|
||
TopologyArm{"arm_low", "connected"},
|
||
TopologyArm{"arm_mid", "louvain"},
|
||
TopologyArm{"arm_high", "kmeans"},
|
||
};
|
||
auto statsForArm = [](const std::string& id) {
|
||
if (id == "arm_high") {
|
||
return makeStats(/*singleton=*/0.05, /*giant=*/0.10, /*gini=*/0.4, /*intra=*/0.7);
|
||
}
|
||
if (id == "arm_mid") {
|
||
return makeStats(/*singleton=*/0.20, /*giant=*/0.30, /*gini=*/0.5, /*intra=*/0.4);
|
||
}
|
||
return makeStats(/*singleton=*/0.50, /*giant=*/0.60, /*gini=*/0.7, /*intra=*/0.2);
|
||
};
|
||
|
||
// Session 1: 10 pulls, persist via auto-save.
|
||
{
|
||
TopologyTunerConfig cfg;
|
||
cfg.enabled = true;
|
||
cfg.statePath = statePath;
|
||
TopologyTuner tuner(cfg);
|
||
tuner.setArms(arms);
|
||
for (int i = 0; i < 10; ++i) {
|
||
auto picked = tuner.selectArm();
|
||
REQUIRE(picked.has_value());
|
||
tuner.observeRebuildStats(picked->id, statsForArm(picked->id));
|
||
}
|
||
REQUIRE(fs::exists(statePath));
|
||
}
|
||
|
||
// Session 2 (simulating daemon restart): load state, continue 20 pulls.
|
||
{
|
||
TopologyTunerConfig cfg;
|
||
cfg.enabled = true;
|
||
cfg.statePath = statePath;
|
||
TopologyTuner tuner(cfg);
|
||
tuner.setArms(arms);
|
||
auto loaded = tuner.loadState(statePath);
|
||
REQUIRE(loaded.has_value());
|
||
for (int i = 0; i < 20; ++i) {
|
||
auto picked = tuner.selectArm();
|
||
REQUIRE(picked.has_value());
|
||
tuner.observeRebuildStats(picked->id, statsForArm(picked->id));
|
||
}
|
||
auto best = tuner.bestArmId();
|
||
REQUIRE(best.has_value());
|
||
CHECK(*best == "arm_high");
|
||
}
|
||
|
||
std::error_code ec;
|
||
fs::remove_all(tmpDir, ec);
|
||
}
|
||
|
||
TEST_CASE("TopologyTuner: auto-save fires when statePath is configured",
|
||
"[unit][daemon][topology_tuner][catch2]") {
|
||
namespace fs = std::filesystem;
|
||
auto tmpDir = fs::temp_directory_path() /
|
||
("yams_topology_tuner_autosave_" +
|
||
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||
fs::create_directories(tmpDir);
|
||
const auto statePath = tmpDir / "tuner_state.json";
|
||
|
||
TopologyTunerConfig cfg;
|
||
cfg.enabled = true;
|
||
cfg.statePath = statePath;
|
||
TopologyTuner tuner(cfg);
|
||
tuner.setArms(defaultArmGrid(1000));
|
||
auto arm = tuner.selectArm();
|
||
REQUIRE(arm.has_value());
|
||
auto good = makeStats(0.05, 0.10, 0.4, 0.5);
|
||
tuner.observeRebuildStats(arm->id, good);
|
||
|
||
// observeRebuildStats with statePath set auto-persists the state.
|
||
CHECK(fs::exists(statePath));
|
||
|
||
std::error_code ec;
|
||
fs::remove_all(tmpDir, ec);
|
||
}
|