// SPDX-License-Identifier: GPL-3.0-or-later // Copyright 2026 YAMS Contributors #include #include #include #include #include #include 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{"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{"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 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 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); }