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 engine + HDBSCAN params",
|
||
"[unit][daemon][topology_tuner][catch2]") {
|
||
auto arms = defaultArmGrid(5183);
|
||
|
||
REQUIRE(!arms.empty());
|
||
|
||
bool seenConnected = false;
|
||
bool seenHdbscanWithSmoothing = false;
|
||
bool seenHdbscanNoSmoothing = false;
|
||
for (const auto& arm : arms) {
|
||
if (arm.engine == "connected") {
|
||
seenConnected = true;
|
||
}
|
||
if (arm.engine == "hdbscan" && arm.featureSmoothingHops > 0) {
|
||
seenHdbscanWithSmoothing = true;
|
||
}
|
||
if (arm.engine == "hdbscan" && arm.featureSmoothingHops == 0) {
|
||
seenHdbscanNoSmoothing = true;
|
||
}
|
||
}
|
||
CHECK(seenConnected);
|
||
CHECK(seenHdbscanWithSmoothing);
|
||
CHECK(seenHdbscanNoSmoothing);
|
||
|
||
// 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", "hdbscan", 4, 4, 0},
|
||
TopologyArm{"arm_b", "hdbscan", 8, 8, 0},
|
||
});
|
||
// 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", "hdbscan", 4, 4, 0},
|
||
TopologyArm{"arm_b", "hdbscan", 8, 8, 0},
|
||
});
|
||
// 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: 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);
|
||
|
||
// Substantially different corpus size → grid changes (cluster sizes scale)
|
||
const bool changed = tuner.rebuildArmGridForCorpusSize(50000);
|
||
if (changed) {
|
||
// Grid was rebuilt — at least one arm id should differ
|
||
bool anyDiff = false;
|
||
auto post = tuner.arms();
|
||
if (post.size() == initialCount) {
|
||
for (std::size_t i = 0; i < post.size(); ++i) {
|
||
if (post[i].id != defaultArmGrid(5000)[i].id) {
|
||
anyDiff = true;
|
||
break;
|
||
}
|
||
}
|
||
} else {
|
||
anyDiff = true;
|
||
}
|
||
CHECK(anyDiff);
|
||
}
|
||
}
|
||
|
||
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", "hdbscan", 4, 4, 0},
|
||
TopologyArm{"arm_mid", "hdbscan", 8, 8, 0},
|
||
TopologyArm{"arm_high", "hdbscan", 16, 16, 0},
|
||
};
|
||
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", "hdbscan", 4, 4, 0},
|
||
TopologyArm{"arm_mid", "hdbscan", 8, 8, 0},
|
||
TopologyArm{"arm_high", "hdbscan", 16, 16, 0},
|
||
};
|
||
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);
|
||
}
|