// SPDX-License-Identifier: GPL-3.0-or-later // Copyright 2026 YAMS Contributors /** * @file tuning_features_catch2_test.cpp * @brief Unit tests for R3 canonical features (featurize, bucketize) and * the combined-reward function. */ #include #include #include #include #include #include using namespace yams::search; using Catch::Approx; // ============================================================================= // featurize() // ============================================================================= TEST_CASE("featurize: zero context maps to zero/neg flags", "[unit][tuning_features]") { TuningContext ctx; const auto f = featurize(ctx); REQUIRE(f.size() == kTuningFeatureDim); // 0..8 are continuous — zero input maps to zero (except index 4,7 which // offset by -1 so a zero symbol density reads as the low end of the // range). CHECK(f[0] == Approx(0.0)); CHECK(f[1] == Approx(-1.0)); // centeredHalf(0) = -1 CHECK(f[2] == Approx(-1.0)); CHECK(f[3] == Approx(-1.0)); CHECK(f[4] == Approx(-1.0)); // (0 / 10) - 1 CHECK(f[5] == Approx(0.0)); CHECK(f[6] == Approx(-1.0)); CHECK(f[7] == Approx(-1.0)); CHECK(f[8] == Approx(0.0)); // Boolean flags at zero map to -1. CHECK(f[9] == Approx(-1.0)); CHECK(f[10] == Approx(-1.0)); } TEST_CASE("featurize: saturation clamps to [-1, 1]", "[unit][tuning_features]") { TuningContext ctx; ctx.docCountLog10 = 50.0; // > 6 saturates ctx.codeRatio = 10.0; // clamped to 1.0 → +1 ctx.proseRatio = -2.0; // clamped to 0.0 → -1 ctx.embeddingCoverage = 1.0; ctx.nativeSymbolDensity = 1e6; // saturates at +1 ctx.pathRelativeDepthAvg = 1e3; // saturates at +1 ctx.binaryRatio = 1.0; ctx.kgEdgeDensity = 1e6; ctx.queryTokenCountLog2 = 1e3; ctx.queryHasVectorPath = 1; ctx.queryHasKgAnchors = 1; const auto f = featurize(ctx); for (double v : f) { CHECK(v >= -1.0); CHECK(v <= 1.0); } CHECK(f[0] == Approx(1.0)); CHECK(f[1] == Approx(1.0)); CHECK(f[2] == Approx(-1.0)); CHECK(f[4] == Approx(1.0)); CHECK(f[9] == Approx(1.0)); CHECK(f[10] == Approx(1.0)); } TEST_CASE("featurize: route flags propagate independently", "[unit][tuning_features]") { // Index 9 = queryHasVectorPath, 10 = queryHasKgAnchors. They map // 0 → -1 and 1 → +1. Audit fix (R1/R2/R3 follow-up) ensures the // search engine writes these from routeDecision instead of leaving // them at 0 — this test pins that feature-slot contract so R4's // LinUCB trains against a non-constant signal. TuningContext ctx; ctx.queryHasVectorPath = 1; ctx.queryHasKgAnchors = 0; const auto vectorOnly = featurize(ctx); CHECK(vectorOnly[9] == Approx(1.0)); CHECK(vectorOnly[10] == Approx(-1.0)); ctx.queryHasVectorPath = 0; ctx.queryHasKgAnchors = 1; const auto kgOnly = featurize(ctx); CHECK(kgOnly[9] == Approx(-1.0)); CHECK(kgOnly[10] == Approx(1.0)); ctx.queryHasVectorPath = 1; ctx.queryHasKgAnchors = 1; const auto both = featurize(ctx); CHECK(both[9] == Approx(1.0)); CHECK(both[10] == Approx(1.0)); } TEST_CASE("featurize: midpoint ratios map near zero", "[unit][tuning_features]") { TuningContext ctx; ctx.codeRatio = 0.5; ctx.proseRatio = 0.5; ctx.embeddingCoverage = 0.5; ctx.binaryRatio = 0.5; const auto f = featurize(ctx); CHECK(f[1] == Approx(0.0)); CHECK(f[2] == Approx(0.0)); CHECK(f[3] == Approx(0.0)); CHECK(f[6] == Approx(0.0)); } TEST_CASE("featurize: NaN in symbol density does not crash", "[unit][tuning_features]") { TuningContext ctx; ctx.nativeSymbolDensity = std::nan(""); // Just ensure we return without a crash and output stays finite. const auto f = featurize(ctx); // index 4 is allowed to be any clamped value; finiteness is not // guaranteed by the clamp helper when input is NaN, so we just assert // no throw. This case is defensive — corpus stats never emit NaN. (void)f; SUCCEED(); } // ============================================================================= // bucketize() // ============================================================================= TEST_CASE("bucketize: zero context produces stable key", "[unit][tuning_features]") { const TuningContext ctx; const std::string key = bucketize(ctx); CHECK(key == "c0/d0/e0/m0/ce0/te0"); } TEST_CASE("bucketize: epoch bumps produce different buckets", "[unit][tuning_features]") { TuningContext a; a.corpusEpoch = 100; TuningContext b; b.corpusEpoch = 101; CHECK(bucketize(a) != bucketize(b)); TuningContext c = a; c.topologyEpoch = 7; CHECK(bucketize(a) != bucketize(c)); } TEST_CASE("bucketize: quartile boundaries", "[unit][tuning_features]") { TuningContext ctx; ctx.codeRatio = 0.24; const auto q0 = bucketize(ctx); ctx.codeRatio = 0.26; const auto q1 = bucketize(ctx); ctx.codeRatio = 0.76; const auto q3 = bucketize(ctx); CHECK(q0 != q1); CHECK(q1 != q3); CHECK(q0.find("c0") != std::string::npos); CHECK(q1.find("c1") != std::string::npos); CHECK(q3.find("c3") != std::string::npos); } TEST_CASE("bucketize: doc-count decade", "[unit][tuning_features]") { TuningContext ctx; ctx.docCountLog10 = 0.5; // < 1 → floor 0 const auto d0 = bucketize(ctx); ctx.docCountLog10 = 3.2; // → floor 3 (thousands) const auto d3 = bucketize(ctx); ctx.docCountLog10 = 99.0; // saturates at 6 const auto d6 = bucketize(ctx); CHECK(d0.find("d0") != std::string::npos); CHECK(d3.find("d3") != std::string::npos); CHECK(d6.find("d6") != std::string::npos); } TEST_CASE("bucketize: media flag triggers on binary-heavy corpora", "[unit][tuning_features]") { TuningContext ctx; ctx.binaryRatio = 0.49; CHECK(bucketize(ctx).find("m0") != std::string::npos); ctx.binaryRatio = 0.5; CHECK(bucketize(ctx).find("m1") != std::string::npos); } // ============================================================================= // combineReward() // ============================================================================= TEST_CASE("combineReward: no labels falls back to proxy", "[unit][tuning_reward]") { ProxyRewardInputs p; p.kgUtility = 0.5; p.freshnessBoost = 0.5; p.agreementBoost = 0.5; const auto r = combineReward(std::nullopt, p); CHECK(r.source == RewardSource::Proxy); // 0.6*0.5 + 0.2*0.5 + 0.2*0.5 = 0.5 CHECK(r.value == Approx(0.5)); } TEST_CASE("combineReward: labels dominate proxy", "[unit][tuning_reward]") { ProxyRewardInputs p; p.kgUtility = 0.9; const auto r = combineReward(std::optional{0.4}, p); // Proxy is non-zero → source reads as Mixed for explainability, but the // value comes from the label channel. CHECK(r.source == RewardSource::Mixed); CHECK(r.value == Approx(0.4)); } TEST_CASE("combineReward: labels without proxy → Labels source", "[unit][tuning_reward]") { ProxyRewardInputs p; const auto r = combineReward(std::optional{0.7}, p); CHECK(r.source == RewardSource::Labels); CHECK(r.value == Approx(0.7)); } TEST_CASE("combineReward: latency over budget penalizes", "[unit][tuning_reward]") { ProxyRewardInputs p; p.kgUtility = 0.5; p.latencyOverBudget = 0.2; const auto r = combineReward(std::nullopt, p); // 0.6*0.5 = 0.3; − 0.2 = 0.1 CHECK(r.value == Approx(0.1)); } TEST_CASE("combineReward: NaN inputs treated as zero", "[unit][tuning_reward]") { ProxyRewardInputs p; p.kgUtility = std::nan(""); p.freshnessBoost = std::nan(""); p.agreementBoost = std::nan(""); p.latencyOverBudget = std::nan(""); const auto r = combineReward(std::nullopt, p); CHECK(r.value == Approx(0.0)); CHECK(r.source == RewardSource::Proxy); } TEST_CASE("combineReward: output clamped to [0,1]", "[unit][tuning_reward]") { ProxyRewardInputs p; p.kgUtility = 10.0; const auto hi = combineReward(std::nullopt, p); CHECK(hi.value == Approx(1.0)); p = {}; p.latencyOverBudget = 5.0; const auto lo = combineReward(std::nullopt, p); CHECK(lo.value == Approx(0.0)); } TEST_CASE("rewardSourceLabel: mapping", "[unit][tuning_reward]") { CHECK(rewardSourceLabel(RewardSource::Labels) == "labels"); CHECK(rewardSourceLabel(RewardSource::Proxy) == "proxy"); CHECK(rewardSourceLabel(RewardSource::Mixed) == "mixed"); }