/** * @file search_engine_config_catch2_test.cpp * @brief Tests for SearchEngineConfig, CorpusProfile, ComponentResult helpers, * and score attribution helpers (search_models.h) * * These are header-only or inline functions at 0% coverage. Tests here * exercise forProfile(), detectProfile(), componentSourceToString(), * isVectorComponent(), isTextAnchoringComponent(), and accumulateComponentScore(). */ #include #include #include #include #include #include #include using namespace yams::search; using Catch::Approx; using yams::metadata::SearchResult; TEST_CASE("navigationZoomLevelToString covers all zoom levels", "[search][config][catch2]") { using Z = SearchEngineConfig::NavigationZoomLevel; CHECK(std::string(SearchEngineConfig::navigationZoomLevelToString(Z::Auto)) == "AUTO"); CHECK(std::string(SearchEngineConfig::navigationZoomLevelToString(Z::Map)) == "MAP"); CHECK(std::string(SearchEngineConfig::navigationZoomLevelToString(Z::Neighborhood)) == "NEIGHBORHOOD"); CHECK(std::string(SearchEngineConfig::navigationZoomLevelToString(Z::Street)) == "STREET"); } // ──────────────────────────────────────────────────────────────────────────────── // forProfile // ──────────────────────────────────────────────────────────────────────────────── TEST_CASE("forProfile preserves profile identity and valid fusion domains", "[search][config][catch2]") { using CP = SearchEngineConfig::CorpusProfile; constexpr std::array profiles = {CP::CODE, CP::PROSE, CP::DOCS, CP::MIXED, CP::CUSTOM}; for (const auto profile : profiles) { const auto cfg = SearchEngineConfig::forProfile(profile); const std::array weights = { cfg.textWeight, cfg.simeonTextWeight, cfg.graphTextWeight, cfg.pathTreeWeight, cfg.kgWeight, cfg.vectorWeight, cfg.graphVectorWeight, cfg.entityVectorWeight, cfg.tagWeight, cfg.metadataWeight, }; CAPTURE(SearchEngineConfig::corpusProfileToString(profile)); CHECK(cfg.corpusProfile == profile); for (const float weight : weights) { CHECK(std::isfinite(weight)); CHECK(weight >= 0.0F); } } } // ──────────────────────────────────────────────────────────────────────────────── // detectProfile // ──────────────────────────────────────────────────────────────────────────────── TEST_CASE("detectProfile empty map returns MIXED", "[search][config][catch2]") { std::unordered_map empty; auto profile = SearchEngineConfig::detectProfile(empty); CHECK(profile == SearchEngineConfig::CorpusProfile::MIXED); } TEST_CASE("detectProfile dominant code extensions returns CODE", "[search][config][catch2]") { std::unordered_map counts = { {".py", 40}, {".cpp", 20}, {".h", 10}, {".md", 5}, {".txt", 5}}; // 70/80 = 87.5% code auto profile = SearchEngineConfig::detectProfile(counts); CHECK(profile == SearchEngineConfig::CorpusProfile::CODE); } TEST_CASE("detectProfile dominant prose extensions returns PROSE", "[search][config][catch2]") { std::unordered_map counts = { {".md", 50}, {".txt", 20}, {".pdf", 10}, {".py", 5}}; // 80/85 = 94% prose auto profile = SearchEngineConfig::detectProfile(counts); CHECK(profile == SearchEngineConfig::CorpusProfile::PROSE); } TEST_CASE("detectProfile mixed code + prose returns DOCS", "[search][config][catch2]") { std::unordered_map counts = { {".py", 40}, {".md", 35}, {".txt", 10}, {".dat", 15}}; // code: 40/100 = 40%, prose: 45/100 = 45% auto profile = SearchEngineConfig::detectProfile(counts); CHECK(profile == SearchEngineConfig::CorpusProfile::DOCS); } TEST_CASE("detectProfile all unknown extensions returns MIXED", "[search][config][catch2]") { std::unordered_map counts = {{".xyz", 50}, {".zyx", 30}, {".qwerty", 20}}; auto profile = SearchEngineConfig::detectProfile(counts); CHECK(profile == SearchEngineConfig::CorpusProfile::MIXED); } TEST_CASE("detectProfile case-insensitive extension matching", "[search][config][catch2]") { // detectProfile lowercases extensions so ".PY" counts as code std::unordered_map counts = { {".PY", 30}, {".CPP", 30}, {".H", 20}, {".md", 5}}; // 80/85 = 94% code (lowercase matches) auto profile = SearchEngineConfig::detectProfile(counts); CHECK(profile == SearchEngineConfig::CorpusProfile::CODE); } // ──────────────────────────────────────────────────────────────────────────────── // componentSourceToString // ──────────────────────────────────────────────────────────────────────────────── TEST_CASE("componentSourceToString covers all sources", "[search][config][catch2]") { using S = ComponentResult::Source; CHECK(std::string(componentSourceToString(S::Text)) == "text"); CHECK(std::string(componentSourceToString(S::SimeonText)) == "simeon_text"); CHECK(std::string(componentSourceToString(S::GraphText)) == "graph_text"); CHECK(std::string(componentSourceToString(S::PathTree)) == "path_tree"); CHECK(std::string(componentSourceToString(S::KnowledgeGraph)) == "kg"); CHECK(std::string(componentSourceToString(S::Vector)) == "vector"); CHECK(std::string(componentSourceToString(S::EntityVector)) == "entity_vector"); CHECK(std::string(componentSourceToString(S::Tag)) == "tag"); CHECK(std::string(componentSourceToString(S::Metadata)) == "metadata"); CHECK(std::string(componentSourceToString(S::Symbol)) == "symbol"); CHECK(std::string(componentSourceToString(S::Unknown)) == "unknown"); } TEST_CASE("componentSourceWeight matches config fields", "[search][config][catch2]") { SearchEngineConfig cfg; cfg.textWeight = 0.41f; cfg.simeonTextWeight = 0.11f; cfg.graphTextWeight = 0.12f; cfg.pathTreeWeight = 0.09f; cfg.kgWeight = 0.07f; cfg.vectorWeight = 0.31f; cfg.graphVectorWeight = 0.13f; cfg.entityVectorWeight = 0.05f; cfg.tagWeight = 0.02f; cfg.metadataWeight = 0.03f; CHECK(yams::search::componentSourceWeight(cfg, ComponentResult::Source::Text) == Approx(0.41f)); CHECK(yams::search::componentSourceWeight(cfg, ComponentResult::Source::SimeonText) == Approx(0.11f)); CHECK(yams::search::componentSourceWeight(cfg, ComponentResult::Source::GraphText) == Approx(0.12f)); CHECK(yams::search::componentSourceWeight(cfg, ComponentResult::Source::PathTree) == Approx(0.09f)); CHECK(yams::search::componentSourceWeight(cfg, ComponentResult::Source::KnowledgeGraph) == Approx(0.07f)); CHECK(yams::search::componentSourceWeight(cfg, ComponentResult::Source::Vector) == Approx(0.31f)); CHECK(yams::search::componentSourceWeight(cfg, ComponentResult::Source::GraphVector) == Approx(0.13f)); CHECK(yams::search::componentSourceWeight(cfg, ComponentResult::Source::EntityVector) == Approx(0.05f)); CHECK(yams::search::componentSourceWeight(cfg, ComponentResult::Source::Tag) == Approx(0.02f)); CHECK(yams::search::componentSourceWeight(cfg, ComponentResult::Source::Metadata) == Approx(0.03f)); CHECK(yams::search::componentSourceWeight(cfg, ComponentResult::Source::Symbol) == Approx(0.0f)); } // ──────────────────────────────────────────────────────────────────────────────── // isVectorComponent / isTextAnchoringComponent // ──────────────────────────────────────────────────────────────────────────────── TEST_CASE("isVectorComponent is true for Vector and EntityVector", "[search][config][catch2]") { using S = ComponentResult::Source; CHECK(isVectorComponent(S::Vector) == true); CHECK(isVectorComponent(S::EntityVector) == true); CHECK(isVectorComponent(S::Text) == false); CHECK(isVectorComponent(S::PathTree) == false); CHECK(isVectorComponent(S::KnowledgeGraph) == false); CHECK(isVectorComponent(S::Tag) == false); CHECK(isVectorComponent(S::Metadata) == false); CHECK(isVectorComponent(S::Symbol) == false); CHECK(isVectorComponent(S::Unknown) == false); } TEST_CASE("isTextAnchoringComponent identifies non-vector components", "[search][config][catch2]") { using S = ComponentResult::Source; CHECK(isTextAnchoringComponent(S::Text) == true); CHECK(isTextAnchoringComponent(S::SimeonText) == true); CHECK(isTextAnchoringComponent(S::GraphText) == true); CHECK(isTextAnchoringComponent(S::PathTree) == true); CHECK(isTextAnchoringComponent(S::KnowledgeGraph) == true); CHECK(isTextAnchoringComponent(S::Tag) == true); CHECK(isTextAnchoringComponent(S::Metadata) == true); CHECK(isTextAnchoringComponent(S::Symbol) == true); CHECK(isTextAnchoringComponent(S::Vector) == false); CHECK(isTextAnchoringComponent(S::EntityVector) == false); CHECK(isTextAnchoringComponent(S::Unknown) == false); } // ──────────────────────────────────────────────────────────────────────────────── // accumulateComponentScore // ──────────────────────────────────────────────────────────────────────────────── TEST_CASE("accumulateComponentScore routes Text to keywordScore", "[search][config][catch2]") { SearchResult r; accumulateComponentScore(r, ComponentResult::Source::Text, 0.5); REQUIRE(r.keywordScore.has_value()); CHECK(r.keywordScore.value() == Approx(0.5)); } TEST_CASE("accumulateComponentScore routes Vector to vectorScore", "[search][config][catch2]") { SearchResult r; accumulateComponentScore(r, ComponentResult::Source::Vector, 0.3); REQUIRE(r.vectorScore.has_value()); CHECK(r.vectorScore.value() == Approx(0.3)); } TEST_CASE("accumulateComponentScore routes EntityVector to vectorScore", "[search][config][catch2]") { SearchResult r; accumulateComponentScore(r, ComponentResult::Source::EntityVector, 0.2); REQUIRE(r.vectorScore.has_value()); CHECK(r.vectorScore.value() == Approx(0.2)); } TEST_CASE("accumulateComponentScore routes KnowledgeGraph to kgScore", "[search][config][catch2]") { SearchResult r; accumulateComponentScore(r, ComponentResult::Source::KnowledgeGraph, 0.4); REQUIRE(r.kgScore.has_value()); CHECK(r.kgScore.value() == Approx(0.4)); } TEST_CASE("accumulateComponentScore routes PathTree to pathScore", "[search][config][catch2]") { SearchResult r; accumulateComponentScore(r, ComponentResult::Source::PathTree, 0.15); REQUIRE(r.pathScore.has_value()); CHECK(r.pathScore.value() == Approx(0.15)); } TEST_CASE("accumulateComponentScore routes Tag to tagScore", "[search][config][catch2]") { SearchResult r; accumulateComponentScore(r, ComponentResult::Source::Tag, 0.1); REQUIRE(r.tagScore.has_value()); CHECK(r.tagScore.value() == Approx(0.1)); } TEST_CASE("accumulateComponentScore routes Metadata to tagScore", "[search][config][catch2]") { SearchResult r; accumulateComponentScore(r, ComponentResult::Source::Metadata, 0.1); REQUIRE(r.tagScore.has_value()); CHECK(r.tagScore.value() == Approx(0.1)); } TEST_CASE("accumulateComponentScore routes Symbol to symbolScore", "[search][config][catch2]") { SearchResult r; accumulateComponentScore(r, ComponentResult::Source::Symbol, 0.25); REQUIRE(r.symbolScore.has_value()); CHECK(r.symbolScore.value() == Approx(0.25)); } TEST_CASE("accumulateComponentScore accumulates multiple calls", "[search][config][catch2]") { SearchResult r; accumulateComponentScore(r, ComponentResult::Source::Vector, 0.3); accumulateComponentScore(r, ComponentResult::Source::EntityVector, 0.2); REQUIRE(r.vectorScore.has_value()); CHECK(r.vectorScore.value() == Approx(0.5)); } TEST_CASE("accumulateComponentScore Unknown source is no-op", "[search][config][catch2]") { SearchResult r; accumulateComponentScore(r, ComponentResult::Source::Unknown, 1.0); CHECK(!r.keywordScore.has_value()); CHECK(!r.vectorScore.has_value()); CHECK(!r.kgScore.has_value()); CHECK(!r.pathScore.has_value()); CHECK(!r.tagScore.has_value()); CHECK(!r.symbolScore.has_value()); } // ──────────────────────────────────────────────────────────────────────────────── // SearchEngineConfig validity and explicit behavior // ──────────────────────────────────────────────────────────────────────────────── TEST_CASE("SearchEngineConfig initializes tunables within supported domains", "[search][config][catch2]") { const SearchEngineConfig cfg; const std::array weights = { cfg.textWeight, cfg.simeonTextWeight, cfg.graphTextWeight, cfg.pathTreeWeight, cfg.kgWeight, cfg.vectorWeight, cfg.graphVectorWeight, cfg.entityVectorWeight, cfg.tagWeight, cfg.metadataWeight, }; for (const float weight : weights) { CHECK(std::isfinite(weight)); CHECK(weight >= 0.0F); } // Do not invent scalar bounds: zero can disable a feature or select an // inherited value, and routing margins are score differences, not probabilities. CHECK(std::isfinite(cfg.similarityThreshold)); CHECK(std::isfinite(cfg.topologyAdaptiveProbeScoreGap)); CHECK(std::isfinite(cfg.topologyNarrowMinBoundaryMargin)); CHECK(std::isfinite(cfg.topologyEvidenceWeight)); CHECK(std::isfinite(cfg.rrfK)); CHECK(cfg.rrfK >= 0.0F); CHECK(std::isfinite(cfg.bm25NormDivisor)); CHECK(std::isfinite(cfg.rerankBlendWeight)); CHECK(cfg.rerankBlendWeight >= 0.0F); CHECK(cfg.rerankBlendWeight <= 1.0F); } TEST_CASE("topologyVectorPolicyToString covers every policy", "[search][config][topology][catch2]") { using Policy = SearchEngineConfig::TopologyVectorPolicy; CHECK(std::string_view(SearchEngineConfig::topologyVectorPolicyToString(Policy::Augment)) == "augment"); CHECK(std::string_view(SearchEngineConfig::topologyVectorPolicyToString(Policy::Narrow)) == "narrow"); CHECK(std::string_view(SearchEngineConfig::topologyVectorPolicyToString(Policy::Shadow)) == "shadow"); CHECK(std::string_view(SearchEngineConfig::topologyVectorPolicyToString(Policy::RouteOnly)) == "route_only"); } TEST_CASE("topology expansion limit resolves explicit and inherited values", "[search][config][topology][catch2]") { SearchEngineConfig cfg; cfg.topologyExpansionOutputLimit = 0; CHECK(cfg.resolvedTopologyExpansionOutputLimit(37) == 37); cfg.topologyExpansionOutputLimit = 91; CHECK(cfg.resolvedTopologyExpansionOutputLimit(37) == 91); } TEST_CASE("SearchEngineConfig preserves typed execution policy across tuning", "[search][config][topology][catch2]") { SearchEngineConfig tuned; tuned.textWeight = 0.25F; SearchEngineConfig configured; configured.topologyRoutingMode = SearchEngineConfig::TopologyRoutingMode::HybridAssist; configured.topologyMaxClusters = 3; configured.topologyMinClusters = 2; configured.topologyMaxSeedDocuments = 24; configured.topologyRoutingAnnCandidateLimit = 16; configured.topologyAdaptiveProbeScoreGap = 0.08F; configured.topologyNarrowMinBoundaryMargin = 0.12F; configured.topologyMaxDocs = 48; configured.topologyExpansionOutputLimit = 96; configured.topologyMedoidBoost = 0.17F; configured.topologyRouteScoringMode = SearchEngineConfig::TopologyRouteScoringMode::SeedCoverage; configured.topologySparseDenseAlpha = 0.7F; configured.topologyMinRouteScore = 0.2F; configured.topologyExpansionSource = SearchEngineConfig::TopologyExpansionSource::GraphNeighbors; configured.topologyGraphNeighborMinScore = 0.4F; configured.topologyGraphNeighborReciprocalOnly = false; configured.topologyGraphWeightedSeedRanking = true; configured.topologyGraphVectorSeedProbe = 16; configured.topologyVectorPolicy = SearchEngineConfig::TopologyVectorPolicy::Narrow; tuned.applyExecutionPolicyFrom(configured); CHECK(tuned.textWeight == Approx(0.25F)); CHECK(tuned.topologyRoutingMode == SearchEngineConfig::TopologyRoutingMode::HybridAssist); CHECK(tuned.topologyMaxClusters == 3); CHECK(tuned.topologyMinClusters == 2); CHECK(tuned.topologyMaxSeedDocuments == 24); CHECK(tuned.topologyRoutingAnnCandidateLimit == 16); CHECK(tuned.topologyAdaptiveProbeScoreGap == Approx(0.08F)); CHECK(tuned.topologyNarrowMinBoundaryMargin == Approx(0.12F)); CHECK(tuned.topologyMaxDocs == 48); CHECK(tuned.topologyExpansionOutputLimit == 96); CHECK(tuned.resolvedTopologyExpansionOutputLimit(32) == 96); CHECK(tuned.topologyMedoidBoost == Approx(0.17F)); CHECK(tuned.topologyRouteScoringMode == SearchEngineConfig::TopologyRouteScoringMode::SeedCoverage); CHECK(tuned.topologySparseDenseAlpha == Approx(0.7F)); CHECK(tuned.topologyMinRouteScore == Approx(0.2F)); CHECK(tuned.topologyExpansionSource == SearchEngineConfig::TopologyExpansionSource::GraphNeighbors); CHECK(tuned.topologyGraphNeighborMinScore == Approx(0.4F)); CHECK_FALSE(tuned.topologyGraphNeighborReciprocalOnly); CHECK(tuned.topologyGraphWeightedSeedRanking); CHECK(tuned.topologyGraphVectorSeedProbe == 16); CHECK(tuned.topologyVectorPolicy == SearchEngineConfig::TopologyVectorPolicy::Narrow); } TEST_CASE("SearchEngineConfig RouteOnly policy is operator-selected and gated", "[search][config][topology][route-only][catch2]") { SECTION("defaults never select RouteOnly or open the evaluation gate") { SearchEngineConfig cfg; CHECK(cfg.topologyVectorPolicy == SearchEngineConfig::TopologyVectorPolicy::Shadow); CHECK_FALSE(cfg.allowUncertifiedRouteOnly); CHECK(std::string_view(SearchEngineConfig::topologyVectorPolicyToString( SearchEngineConfig::TopologyVectorPolicy::RouteOnly)) == "route_only"); } SECTION("tuning replaces weights but keeps the policy and the gate") { SearchEngineConfig tuned; tuned.textWeight = 0.25F; SearchEngineConfig configured; configured.topologyVectorPolicy = SearchEngineConfig::TopologyVectorPolicy::RouteOnly; configured.allowUncertifiedRouteOnly = true; tuned.applyExecutionPolicyFrom(configured); CHECK(tuned.textWeight == Approx(0.25F)); CHECK(tuned.topologyVectorPolicy == SearchEngineConfig::TopologyVectorPolicy::RouteOnly); CHECK(tuned.allowUncertifiedRouteOnly); } SECTION("a closed gate survives tuning too") { SearchEngineConfig tuned; tuned.allowUncertifiedRouteOnly = true; SearchEngineConfig configured; tuned.applyExecutionPolicyFrom(configured); CHECK_FALSE(tuned.allowUncertifiedRouteOnly); CHECK(tuned.topologyVectorPolicy == SearchEngineConfig::TopologyVectorPolicy::Shadow); } } // ──────────────────────────────────────────────────────────────────────────────── // SearchResponse helpers // ──────────────────────────────────────────────────────────────────────────────── TEST_CASE("SearchResponse hasResults / isComplete", "[search][config][catch2]") { SearchResponse resp; CHECK(resp.hasResults() == false); CHECK(resp.isComplete() == true); resp.results.push_back(SearchResult{}); CHECK(resp.hasResults() == true); resp.timedOutComponents.push_back("slow_component"); CHECK(resp.isComplete() == false); }