diff --git a/.kiro/specs/evolab/tasks.md b/.kiro/specs/evolab/tasks.md index 97770daa..2e3bba4c 100644 --- a/.kiro/specs/evolab/tasks.md +++ b/.kiro/specs/evolab/tasks.md @@ -114,13 +114,17 @@ - _Requirements: FR-LS-003_ - _Pull Request: Ready for review_ -- [ ] 6.2 Integrate Lin-Kernighan with Multi-Armed Bandit scheduler - - Extend AdaptiveOperatorSelector to support local search operators alongside crossover - - Implement performance tracking for Lin-Kernighan moves (improvement rate, execution time) - - Add Lin-Kernighan as optional local search component in configuration system - - Create hybrid Memetic GA configuration templates combining crossover and Lin-Kernighan +- [x] 6.2 Integrate Lin-Kernighan with Multi-Armed Bandit scheduler + - ✅ Extended AdaptiveOperatorSelector pattern to support local search operators alongside crossover + - ✅ Implemented LocalSearchOperator concept for type-safe local search algorithm interface + - ✅ Created AdaptiveLocalSearchSelector class with UCB and Thompson Sampling support + - ✅ Added performance tracking: execution time (chrono-based) and improvement rate via OperatorStats + - ✅ Comprehensive test suite with 21 tests (test_mab_local_search.cpp) - all passing + - ✅ Support for LinKernighan, TwoOpt, and Random2Opt local search operators + - ⚠️ Configuration system integration deferred (requires runtime operator selection design) + - ⚠️ Hybrid Memetic GA templates deferred (awaiting config system enhancement) - _Requirements: FR-LS-003, FR-AC-001_ - - _Note: Basic LK integration with GA complete; MAB scheduler extension is future enhancement_ + - _Note: Core MAB local search integration complete; config templates are future enhancement_ #### 7. Diversity Maintenance Implementation - [ ] 7.1 Implement population diversity metrics diff --git a/include/evolab/evolab.hpp b/include/evolab/evolab.hpp index e47c9350..616353e5 100644 --- a/include/evolab/evolab.hpp +++ b/include/evolab/evolab.hpp @@ -61,6 +61,7 @@ #include // Local search algorithms - memetic algorithm components +#include #include // Adaptive operator scheduling - multi-armed bandit approaches diff --git a/include/evolab/schedulers/mab.hpp b/include/evolab/schedulers/mab.hpp index e7aaaa75..50b1038c 100644 --- a/include/evolab/schedulers/mab.hpp +++ b/include/evolab/schedulers/mab.hpp @@ -1,24 +1,57 @@ #pragma once #include +#include #include #include #include +#include #include #include +#include +#include +#include +#include #include +#include #include -namespace evolab::schedulers { +#include -template -concept CrossoverOperator = - requires(T op, Problem problem, typename Problem::GenomeT genome, std::mt19937& rng) { - { - op.cross(problem, genome, genome, rng) - } -> std::convertible_to>; - }; +namespace evolab::schedulers { +// Thread-local random number generator for default scheduler parameters +// Uses std::seed_seq to combine entropy from multiple sources (random_device + +// high_resolution_clock + thread_id) to avoid seed collision across threads, especially on +// platforms where random_device may be deterministic +inline std::mt19937& get_thread_rng() { + static thread_local std::mt19937 gen = [] { + std::random_device rd; + auto clock_seed = std::chrono::high_resolution_clock::now().time_since_epoch().count(); + auto thread_id_hash = std::hash{}(std::this_thread::get_id()); + + // Combine entropy from random_device, high-res clock, and thread ID + std::seed_seq ssq{rd(), static_cast(clock_seed), + static_cast(clock_seed >> 32), + static_cast(thread_id_hash)}; + return std::mt19937(ssq); + }(); + return gen; +} + +// TODO(performance): Aggregate the per-invocation timing (already tracked in selectors +// via last_execution_time_) into OperatorStats for cost-benefit analysis. Consider adding: +// - double total_execution_time: cumulative time spent on this operator +// - double avg_execution_time: average execution time per selection +// This would enable direct comparison of performance vs. reward trade-offs +// across operators in the adaptive selection process. + +// TODO(validation): Empirical validation required before v1.0 release +// This MAB scheduler implementation is functionally correct and unit-tested, but requires +// comprehensive empirical validation for research-grade quality. See detailed validation +// plan in Issue #33: https://github.com/lv416e/evolab/issues/33 +// Priority: HIGH - Blocks v1.0 release and research publications +// struct OperatorStats { double total_reward = 0.0; size_t selection_count = 0; @@ -59,6 +92,10 @@ class UCBScheduler { rng_(rng) {} int select_operator() { + if (stats_.empty()) { + throw std::runtime_error("UCBScheduler: no operators configured"); + } + total_selections_++; std::vector best_operators; @@ -107,13 +144,6 @@ class UCBScheduler { } total_selections_ = 0; } - - private: - static std::mt19937& get_thread_rng() { - static thread_local std::random_device rd; - static thread_local std::mt19937 gen(rd()); - return gen; - } }; class ThompsonSamplingScheduler { @@ -153,6 +183,10 @@ class ThompsonSamplingScheduler { reward_threshold_(reward_threshold) {} int select_operator() { + if (distributions_.empty()) { + throw std::runtime_error("ThompsonSamplingScheduler: no operators configured"); + } + std::vector samples(distributions_.size()); for (size_t i = 0; i < distributions_.size(); ++i) { @@ -198,57 +232,113 @@ class ThompsonSamplingScheduler { void set_reward_threshold(double threshold) { reward_threshold_ = threshold; } double get_reward_threshold() const { return reward_threshold_; } - - private: - static std::mt19937& get_thread_rng() { - static thread_local std::random_device rd; - static thread_local std::mt19937 gen(rd()); - return gen; - } }; +// TODO(refactor): AdaptiveOperatorSelector and AdaptiveLocalSearchSelector share ~110 lines of +// nearly identical code (constructor validation, reporting, getters, reset logic). The duplication +// threshold has been reached. A templated base class or CRTP pattern could eliminate most of this +// without complex metaprogramming - the main challenge is abstracting the different return types +// (pair vs Fitness) and parameter lists in the apply methods. This refactoring should be +// prioritized before adding a third selector type to avoid further code multiplication. +// See: https://github.com/lv416e/evolab/issues/32 + +/// @brief Adaptive crossover operator selector using multi-armed bandit algorithms +/// +/// This class enables automatic selection of the best-performing crossover operator +/// based on historical performance using UCB or Thompson Sampling schedulers. +/// +/// @warning NOT THREAD-SAFE: This class maintains mutable state (current_selection_, +/// tracking_improvement_, last_fitness_improvement_, last_execution_time_) and +/// is NOT safe for concurrent access from multiple threads. Sharing a selector +/// across threads will cause race conditions leading to corrupted MAB learning +/// and potentially incorrect research results. +/// +/// @note For parallel GAs (e.g., Island Model, parallel populations): Create one +/// selector instance per thread/island. Each thread must have its own independent +/// selector to ensure correct learning and avoid data races. +/// +/// @tparam SchedulerType The MAB scheduler type (UCBScheduler or ThompsonSamplingScheduler) +/// @tparam Problem The optimization problem type (must satisfy Problem concept) template class AdaptiveOperatorSelector { private: + using CrossoverFn = + std::function( + const Problem&, const typename Problem::GenomeT&, const typename Problem::GenomeT&, + std::mt19937&)>; + SchedulerType scheduler_; - std::vector( - const Problem&, const typename Problem::GenomeT&, const typename Problem::GenomeT&, - std::mt19937&)>> - operators_; + std::vector operators_; std::vector operator_names_; int current_selection_; double last_fitness_improvement_; + double last_execution_time_; bool tracking_improvement_; public: template explicit AdaptiveOperatorSelector(size_t num_operators, Args&&... args) : scheduler_(num_operators, std::forward(args)...), current_selection_(-1), - last_fitness_improvement_(0.0), tracking_improvement_(false) { + last_fitness_improvement_(0.0), last_execution_time_(0.0), tracking_improvement_(false) { + if (num_operators == 0) { + throw std::invalid_argument( + "AdaptiveOperatorSelector must be configured with at least one operator."); + } operators_.reserve(num_operators); operator_names_.reserve(num_operators); } - template OpType> - void add_operator(const OpType& op, const std::string& name) { - operator_names_.push_back(name); + template OpType> + void add_operator(OpType&& op, std::string name) { + if (operators_.size() >= scheduler_.get_stats().size()) { + std::stringstream err_msg; + err_msg << "Cannot add more crossover operators than the number specified in the " + << "selector's constructor. Maximum allowed: " << scheduler_.get_stats().size() + << ", current: " << operators_.size() + << ". Extra operators will never be selected."; + throw std::logic_error(err_msg.str()); + } + operator_names_.emplace_back(std::move(name)); operators_.emplace_back( - [op](const Problem& problem, const typename Problem::GenomeT& parent1, - const typename Problem::GenomeT& parent2, - std::mt19937& rng) { return op.cross(problem, parent1, parent2, rng); }); + [op = std::forward(op)]( + const Problem& problem, const typename Problem::GenomeT& parent1, + const typename Problem::GenomeT& parent2, + std::mt19937& rng) { return op.cross(problem, parent1, parent2, rng); }); } std::pair apply_crossover(const Problem& problem, const typename Problem::GenomeT& parent1, const typename Problem::GenomeT& parent2, std::mt19937& rng) { + if (operators_.empty()) { + throw std::logic_error("Cannot apply crossover: no operators have been added."); + } + + if (tracking_improvement_) { + throw std::logic_error( + "apply_crossover called again before report_fitness_improvement was called for the " + "previous operation."); + } + current_selection_ = scheduler_.select_operator(); - tracking_improvement_ = true; - if (current_selection_ >= 0 && current_selection_ < static_cast(operators_.size())) { - return operators_[current_selection_](problem, parent1, parent2, rng); + if (current_selection_ < 0 || current_selection_ >= static_cast(operators_.size())) { + std::stringstream err_msg; + err_msg << "Selected crossover operator index " << current_selection_ + << " is out of bounds. This can happen if the number of " + << "operators added via add_operator() does not match the num_operators " + "argument in " + << "the constructor. Expected " << scheduler_.get_stats().size() + << " operators, but only " << operators_.size() << " were added."; + throw std::out_of_range(err_msg.str()); } - return {parent1, parent2}; + auto start_time = std::chrono::steady_clock::now(); + auto result = operators_[current_selection_](problem, parent1, parent2, rng); + auto end_time = std::chrono::steady_clock::now(); + last_execution_time_ = std::chrono::duration(end_time - start_time).count(); + + tracking_improvement_ = true; + return result; } void report_fitness_improvement(double improvement) { @@ -259,8 +349,30 @@ class AdaptiveOperatorSelector { } } + /// @brief Report fitness improvement using old and new fitness values + /// + /// This is a convenience method for MINIMIZATION problems (TSP, VRP, CVRP, QAP, etc.) + /// where improvement = old_fitness - new_fitness (lower fitness is better). + /// + /// @warning MINIMIZATION PROBLEMS ONLY: This method assumes minimization objectives. + /// For maximization problems, you must calculate improvement manually and use + /// report_fitness_improvement() directly: + /// @code + /// double improvement = new_fitness - old_fitness; // For maximization + /// selector.report_fitness_improvement(improvement); + /// @endcode + /// + /// @param old_fitness Fitness value before crossover operation + /// @param new_fitness Fitness value after crossover operation + /// + /// @example + /// @code + /// // For minimization problems (TSP): + /// selector.report_fitness_change(100.0, 90.0); // improvement = 10.0 (better) + /// selector.report_fitness_change(90.0, 100.0); // improvement = -10.0 (worse) + /// @endcode void report_fitness_change(double old_fitness, double new_fitness) { - double improvement = old_fitness - new_fitness; // Assuming minimization + double improvement = old_fitness - new_fitness; // Minimization: lower is better report_fitness_improvement(improvement); } @@ -272,6 +384,7 @@ class AdaptiveOperatorSelector { scheduler_.reset(); current_selection_ = -1; last_fitness_improvement_ = 0.0; + last_execution_time_ = 0.0; tracking_improvement_ = false; } @@ -279,6 +392,7 @@ class AdaptiveOperatorSelector { int get_last_selection() const { return current_selection_; } double get_last_improvement() const { return last_fitness_improvement_; } + double get_last_execution_time() const { return last_execution_time_; } }; template @@ -287,4 +401,167 @@ using UCBOperatorSelector = AdaptiveOperatorSelector; template using ThompsonOperatorSelector = AdaptiveOperatorSelector; +/// @brief Adaptive local search operator selector using multi-armed bandit algorithms +/// +/// This class enables automatic selection of the best-performing local search operator +/// based on historical performance using UCB or Thompson Sampling schedulers. +/// +/// @warning NOT THREAD-SAFE: This class maintains mutable state (current_selection_, +/// tracking_improvement_, last_fitness_improvement_, last_execution_time_) and +/// is NOT safe for concurrent access from multiple threads. Sharing a selector +/// across threads will cause race conditions leading to corrupted MAB learning +/// and potentially incorrect research results. +/// +/// @note For parallel GAs (e.g., Island Model, parallel populations): Create one +/// selector instance per thread/island. Each thread must have its own independent +/// selector to ensure correct learning and avoid data races. +/// +/// @tparam SchedulerType The MAB scheduler type (UCBScheduler or ThompsonSamplingScheduler) +/// @tparam Problem The optimization problem type (must satisfy Problem concept) +template +class AdaptiveLocalSearchSelector { + private: + using LocalSearchFn = + std::function; + + SchedulerType scheduler_; + std::vector operators_; + std::vector operator_names_; + int current_selection_; + double last_fitness_improvement_; + double last_execution_time_; + bool tracking_improvement_; + + public: + template + explicit AdaptiveLocalSearchSelector(size_t num_operators, Args&&... args) + : scheduler_(num_operators, std::forward(args)...), current_selection_(-1), + last_fitness_improvement_(0.0), last_execution_time_(0.0), tracking_improvement_(false) { + if (num_operators == 0) { + throw std::invalid_argument( + "AdaptiveLocalSearchSelector must be configured with at least one operator."); + } + operators_.reserve(num_operators); + operator_names_.reserve(num_operators); + } + + // TODO(design): Consider relaxing LocalSearchOperator concept to support + // stateful algorithms (e.g., Tabu Search) by accepting non-const operators. + // This would require making the lambda mutable: + // [op = std::move(op)](...) mutable { return op.improve(...); } + // The concept in core/concepts.hpp would need to accept non-const L&. + // This would improve extensibility for stateful local search algorithms. + template OpType> + void add_operator(OpType&& op, std::string name) { + if (operators_.size() >= scheduler_.get_stats().size()) { + std::stringstream err_msg; + err_msg << "Cannot add more local search operators than the number specified in the " + << "selector's constructor. Maximum allowed: " << scheduler_.get_stats().size() + << ", current: " << operators_.size() + << ". Extra operators will never be selected."; + throw std::logic_error(err_msg.str()); + } + operator_names_.emplace_back(std::move(name)); + operators_.emplace_back([op = std::forward(op)](const Problem& problem, + typename Problem::GenomeT& genome, + std::mt19937& rng) { + return op.improve(problem, genome, rng); + }); + } + + core::Fitness apply_local_search(const Problem& problem, typename Problem::GenomeT& genome, + std::mt19937& rng) { + if (operators_.empty()) { + throw std::logic_error("Cannot apply local search: no operators have been added."); + } + + if (tracking_improvement_) { + throw std::logic_error( + "apply_local_search called again before report_fitness_improvement was called for " + "the previous operation."); + } + + current_selection_ = scheduler_.select_operator(); + + if (current_selection_ < 0 || current_selection_ >= static_cast(operators_.size())) { + std::stringstream err_msg; + err_msg << "Selected local search operator index " << current_selection_ + << " is out of bounds. This can happen if the number of " + << "operators added via add_operator() does not match the num_operators " + "argument in " + << "the constructor. Expected " << scheduler_.get_stats().size() + << " operators, but only " << operators_.size() << " were added."; + throw std::out_of_range(err_msg.str()); + } + + auto start_time = std::chrono::steady_clock::now(); + core::Fitness result = operators_[current_selection_](problem, genome, rng); + + auto end_time = std::chrono::steady_clock::now(); + last_execution_time_ = std::chrono::duration(end_time - start_time).count(); + + tracking_improvement_ = true; + return result; + } + + void report_fitness_improvement(double improvement) { + if (tracking_improvement_ && current_selection_ >= 0) { + last_fitness_improvement_ = improvement; + scheduler_.update_reward(current_selection_, improvement); + tracking_improvement_ = false; + } + } + + /// @brief Report fitness improvement using old and new fitness values + /// + /// This is a convenience method for MINIMIZATION problems (TSP, VRP, CVRP, QAP, etc.) + /// where improvement = old_fitness - new_fitness (lower fitness is better). + /// + /// @warning MINIMIZATION PROBLEMS ONLY: This method assumes minimization objectives. + /// For maximization problems, you must calculate improvement manually and use + /// report_fitness_improvement() directly: + /// @code + /// double improvement = new_fitness - old_fitness; // For maximization + /// selector.report_fitness_improvement(improvement); + /// @endcode + /// + /// @param old_fitness Fitness value before local search operation + /// @param new_fitness Fitness value after local search operation + /// + /// @example + /// @code + /// // For minimization problems (TSP): + /// selector.report_fitness_change(100.0, 90.0); // improvement = 10.0 (better) + /// selector.report_fitness_change(90.0, 100.0); // improvement = -10.0 (worse) + /// @endcode + void report_fitness_change(double old_fitness, double new_fitness) { + double improvement = old_fitness - new_fitness; // Minimization: lower is better + report_fitness_improvement(improvement); + } + + const std::vector& get_operator_stats() const { return scheduler_.get_stats(); } + + const std::vector& get_operator_names() const { return operator_names_; } + + void reset_stats() { + scheduler_.reset(); + current_selection_ = -1; + last_fitness_improvement_ = 0.0; + last_execution_time_ = 0.0; + tracking_improvement_ = false; + } + + size_t get_operator_count() const { return operators_.size(); } + + int get_last_selection() const { return current_selection_; } + double get_last_improvement() const { return last_fitness_improvement_; } + double get_last_execution_time() const { return last_execution_time_; } +}; + +template +using UCBLocalSearchSelector = AdaptiveLocalSearchSelector; + +template +using ThompsonLocalSearchSelector = AdaptiveLocalSearchSelector; + } // namespace evolab::schedulers diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 20fe14cd..365917e7 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -45,6 +45,10 @@ add_executable(test_lk_integration test_lk_integration.cpp) target_link_libraries(test_lk_integration PRIVATE evolab) target_compile_features(test_lk_integration PRIVATE cxx_std_23) +add_executable(test_mab_local_search test_mab_local_search.cpp) +target_link_libraries(test_mab_local_search PRIVATE evolab) +target_compile_features(test_mab_local_search PRIVATE cxx_std_23) + # Register candidate list tests with CTest add_test(NAME CandidateListTests COMMAND test_candidate_list) set_tests_properties(CandidateListTests PROPERTIES LABELS "unit;tsp;candidate-list") @@ -61,6 +65,10 @@ set_tests_properties(LinKernighanTests PROPERTIES LABELS "unit;local-search;lk") add_test(NAME LinKernighanIntegrationTests COMMAND test_lk_integration) set_tests_properties(LinKernighanIntegrationTests PROPERTIES LABELS "integration;local-search;lk;memetic") +# Register MAB local search integration tests with CTest +add_test(NAME MABLocalSearchTests COMMAND test_mab_local_search) +set_tests_properties(MABLocalSearchTests PROPERTIES LABELS "integration;mab;local-search;adaptive") + # Conditional TBB parallel tests if(TBB_FOUND) add_executable(test_parallel test_parallel.cpp) diff --git a/tests/test_helper.hpp b/tests/test_helper.hpp index c5cae8e2..23b44abe 100644 --- a/tests/test_helper.hpp +++ b/tests/test_helper.hpp @@ -60,6 +60,12 @@ struct TestResult { " >= " + std::to_string(min_value) + ")"); } + void assert_ge(double value, double min_value, const std::string& message, double eps = 0.0) { + assert_true(value >= min_value - eps, message + " (" + std::to_string(value) + + " >= " + std::to_string(min_value) + " - " + + std::to_string(eps) + ")"); + } + void assert_lt(int value, int max_value, const std::string& message) { assert_true(value < max_value, message + " (" + std::to_string(value) + " < " + std::to_string(max_value) + ")"); @@ -70,6 +76,26 @@ struct TestResult { std::to_string(min_value) + ")"); } + void assert_gt(double value, double min_value, const std::string& message, double eps = 1e-9) { + assert_true(value > min_value + eps, + message + " (" + std::to_string(value) + " > " + std::to_string(min_value) + + " + " + std::to_string(eps) + + ", diff: " + std::to_string(value - min_value) + ")"); + } + + void assert_le(double value, double max_value, const std::string& message, double tol = 1e-9) { + assert_true(value <= max_value + tol, message + " (" + std::to_string(value) + + " <= " + std::to_string(max_value) + " + " + + std::to_string(tol) + ")"); + } + + void assert_lt(double value, double max_value, const std::string& message, double tol = 1e-9) { + assert_true(value < max_value - tol, + message + " (" + std::to_string(value) + " < " + std::to_string(max_value) + + " - " + std::to_string(tol) + + ", diff: " + std::to_string(max_value - value) + ")"); + } + void print_summary() { std::cout << "\n=== Test Summary ===\n"; std::cout << "Passed: " << passed << "\n"; diff --git a/tests/test_mab_local_search.cpp b/tests/test_mab_local_search.cpp new file mode 100644 index 00000000..4499df59 --- /dev/null +++ b/tests/test_mab_local_search.cpp @@ -0,0 +1,452 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "test_helper.hpp" + +// Note: All nested namespace using directives below are required. +// While `using namespace evolab;` brings the top-level namespace into scope, +// it does NOT automatically import nested namespaces (C++ standard behavior). +// Without these explicit directives, types like TSP, LinKernighan, TwoOpt, etc. +// would require fully qualified names, resulting in verbose test code. +// Removing these directives causes 20+ compilation errors - verified by testing. +using namespace evolab; +using namespace evolab::schedulers; // UCBLocalSearchSelector, ThompsonLocalSearchSelector +using namespace evolab::local_search; // LinKernighan, TwoOpt, Random2Opt +using namespace evolab::operators; // OrderCrossover, PMXCrossover, etc. +using namespace evolab::problems; // TSP + +// Helper function to create a small TSP instance for testing +struct TestTSPInstance { + TSP tsp; + std::vector tour; + + TestTSPInstance() : tsp(create_test_cities()), tour(tsp.num_cities()) { + std::iota(tour.begin(), tour.end(), 0); + } + + private: + static std::vector> create_test_cities() { + return {{0.0, 0.0}, {1.0, 0.0}, {2.0, 0.0}, {3.0, 0.0}, {4.0, 0.0}, + {0.0, 1.0}, {1.0, 1.0}, {2.0, 1.0}, {3.0, 1.0}, {4.0, 1.0}}; + } +}; + +// Test fixture for UCBLocalSearchSelector tests +struct UCBSelectorFixture { + std::mt19937 rng; + TestTSPInstance tsp_instance; + UCBLocalSearchSelector selector; + + explicit UCBSelectorFixture(size_t num_ops = 2) : rng(42), selector(num_ops, 2.0, rng) {} + + void add_default_operators() { + selector.add_operator(LinKernighan(20, 5), "LinKernighan"); + selector.add_operator(TwoOpt(), "TwoOpt"); + } +}; + +// Test fixture for ThompsonLocalSearchSelector tests +struct ThompsonSelectorFixture { + std::mt19937 rng; + TestTSPInstance tsp_instance; + ThompsonLocalSearchSelector selector; + + explicit ThompsonSelectorFixture(size_t num_ops = 2, double reward_threshold = 0.0) + : rng(42), selector(num_ops, reward_threshold, rng) {} + + void add_default_operators() { + selector.add_operator(LinKernighan(20, 5), "LinKernighan"); + selector.add_operator(TwoOpt(), "TwoOpt"); + } +}; + +// Test LocalSearchOperator concept exists +void test_local_search_operator_concept(TestResult& result) { + // This should compile if the concept exists + static_assert(core::LocalSearchOperator); + static_assert(core::LocalSearchOperator); + static_assert(core::LocalSearchOperator); + + result.assert_true(true, "LocalSearchOperator concept compiles"); +} + +// Test AdaptiveLocalSearchSelector class exists +void test_adaptive_local_search_selector_exists(TestResult& result) { + std::mt19937 rng(42); + // Create selector with UCB scheduler + UCBLocalSearchSelector selector(2, 2.0, rng); + + result.assert_eq(selector.get_operator_count(), static_cast(0), + "Selector initializes with zero operators"); +} + +// Test adding local search operators +void test_add_local_search_operators(TestResult& result) { + std::mt19937 rng(42); + UCBLocalSearchSelector selector(3, 2.0, rng); + + LinKernighan lk(20, 5); + TwoOpt two_opt; + Random2Opt random_2opt(100); + + selector.add_operator(lk, "LinKernighan"); + selector.add_operator(two_opt, "TwoOpt"); + selector.add_operator(random_2opt, "Random2Opt"); + + result.assert_eq(selector.get_operator_count(), static_cast(3), + "Selector has correct operator count"); + result.assert_eq(selector.get_operator_names().size(), static_cast(3), + "Selector has correct number of operator names"); + result.assert_true(selector.get_operator_names()[0] == "LinKernighan", + "First operator name is LinKernighan"); + result.assert_true(selector.get_operator_names()[1] == "TwoOpt", + "Second operator name is TwoOpt"); + result.assert_true(selector.get_operator_names()[2] == "Random2Opt", + "Third operator name is Random2Opt"); +} + +// Test applying local search via selector +void test_apply_local_search(TestResult& result) { + UCBSelectorFixture fixture; + fixture.add_default_operators(); + + auto initial_fitness = fixture.tsp_instance.tsp.evaluate(fixture.tsp_instance.tour); + auto tour_copy = fixture.tsp_instance.tour; + + // Apply local search + auto result_fitness = + fixture.selector.apply_local_search(fixture.tsp_instance.tsp, tour_copy, fixture.rng); + + // Result should have improved (strict improvement expected for non-optimal initial tour) + result.assert_lt(result_fitness.value, initial_fitness.value, + "Local search should improve the non-optimal initial tour"); + result.assert_ge(fixture.selector.get_last_selection(), 0, "Selected operator is non-negative"); + result.assert_lt(fixture.selector.get_last_selection(), 2, + "Selected operator is within bounds"); +} + +// Test performance tracking for local search +void test_performance_tracking(TestResult& result) { + UCBSelectorFixture fixture; + fixture.add_default_operators(); + + // Perform multiple applications + for (int i = 0; i < 10; ++i) { + auto tour_copy = fixture.tsp_instance.tour; + auto initial_fitness = fixture.tsp_instance.tsp.evaluate(tour_copy); + + auto final_fitness = + fixture.selector.apply_local_search(fixture.tsp_instance.tsp, tour_copy, fixture.rng); + + // Report improvement + fixture.selector.report_fitness_improvement(initial_fitness.value - final_fitness.value); + } + + // Check that stats are tracked + const auto& stats = fixture.selector.get_operator_stats(); + result.assert_eq(stats.size(), static_cast(2), "Selector has stats for both operators"); + + size_t total_selections = 0; + double total_reward_sum = 0.0; + for (const auto& stat : stats) { + total_selections += stat.selection_count; + total_reward_sum += stat.total_reward; + } + result.assert_eq(total_selections, static_cast(10), + "Total selections equals number of applications"); + + // Verify that rewards are properly accumulated (local search should improve fitness) + result.assert_gt(total_reward_sum, 0.0, + "Total rewards accumulated across all operators should be positive"); +} + +// Test execution time tracking +void test_execution_time_tracking(TestResult& result) { + UCBSelectorFixture fixture; + fixture.add_default_operators(); + + fixture.selector.apply_local_search(fixture.tsp_instance.tsp, fixture.tsp_instance.tour, + fixture.rng); + + // Check that execution time was recorded + result.assert_ge(fixture.selector.get_last_execution_time(), 0.0, + "Execution time is non-negative"); +} + +// Test improvement rate tracking +void test_improvement_rate_tracking(TestResult& result) { + ThompsonSelectorFixture fixture(2, 0.0); + fixture.add_default_operators(); + + // Perform multiple applications and track improvements + for (int i = 0; i < 20; ++i) { + auto tour_copy = fixture.tsp_instance.tour; + auto initial_fitness = fixture.tsp_instance.tsp.evaluate(tour_copy); + + auto final_fitness = + fixture.selector.apply_local_search(fixture.tsp_instance.tsp, tour_copy, fixture.rng); + double improvement = initial_fitness.value - final_fitness.value; + + fixture.selector.report_fitness_improvement(improvement); + } + + // Check improvement rate statistics + const auto& stats = fixture.selector.get_operator_stats(); + bool at_least_one_operator_selected = false; + for (const auto& stat : stats) { + if (stat.selection_count > 0) { + at_least_one_operator_selected = true; + // Since we always start from the same non-optimal tour and local search + // operators (LinKernighan, TwoOpt) are deterministic improvers with + // reward_threshold = 0.0, every application should succeed + result.assert_eq(stat.success_rate, 1.0, + "Success rate should be 1.0 for consistently improving operators"); + } + } + result.assert_true(at_least_one_operator_selected, + "At least one operator should have been selected"); +} + +// Test Thompson Sampling with local search +void test_thompson_sampling_integration(TestResult& result) { + ThompsonSelectorFixture fixture(2, 0.0); + fixture.add_default_operators(); + + // Run multiple iterations + for (int i = 0; i < 10; ++i) { + auto tour_copy = fixture.tsp_instance.tour; + auto initial_fitness = fixture.tsp_instance.tsp.evaluate(tour_copy); + auto final_fitness = + fixture.selector.apply_local_search(fixture.tsp_instance.tsp, tour_copy, fixture.rng); + double improvement = initial_fitness.value - final_fitness.value; + fixture.selector.report_fitness_improvement(improvement); + } + + // Verify selector state + result.assert_eq(fixture.selector.get_operator_count(), static_cast(2), + "Selector has correct operator count"); + const auto& stats = fixture.selector.get_operator_stats(); + result.assert_eq(stats.size(), static_cast(2), "Selector has stats for both operators"); +} + +// Test error handling: zero operators in crossover selector constructor +void test_error_zero_operators_crossover(TestResult& result) { + std::mt19937 rng(42); + + bool caught_exception = false; + try { + UCBOperatorSelector selector(0, 2.0, rng); + } catch (const std::invalid_argument& e) { + caught_exception = true; + result.assert_true(std::string(e.what()).find("at least one operator") != std::string::npos, + "Exception message mentions at least one operator requirement"); + } + + result.assert_true( + caught_exception, + "Constructing crossover selector with zero operators throws invalid_argument"); +} + +// Test error handling: zero operators in local search selector constructor +void test_error_zero_operators_local_search(TestResult& result) { + std::mt19937 rng(42); + + bool caught_exception = false; + try { + UCBLocalSearchSelector selector(0, 2.0, rng); + } catch (const std::invalid_argument& e) { + caught_exception = true; + result.assert_true(std::string(e.what()).find("at least one operator") != std::string::npos, + "Exception message mentions at least one operator requirement"); + } + + result.assert_true( + caught_exception, + "Constructing local search selector with zero operators throws invalid_argument"); +} + +// Test error handling: adding too many operators +void test_error_too_many_operators(TestResult& result) { + UCBSelectorFixture fixture(2); + fixture.add_default_operators(); // Adds 2 operators (LinKernighan, TwoOpt) + + // Try to add a third operator when only 2 were specified in constructor + bool caught_exception = false; + try { + fixture.selector.add_operator(Random2Opt(50), "Random2Opt"); + } catch (const std::logic_error& e) { + caught_exception = true; + result.assert_true(std::string(e.what()).find("Cannot add more") != std::string::npos, + "Exception message mentions adding too many operators"); + } + + result.assert_true(caught_exception, "Adding too many operators throws logic_error"); +} + +// Test error handling: applying with no operators +void test_error_no_operators(TestResult& result) { + UCBSelectorFixture fixture(2); + // Do not add any operators + + bool caught_exception = false; + try { + auto tour_copy = fixture.tsp_instance.tour; + fixture.selector.apply_local_search(fixture.tsp_instance.tsp, tour_copy, fixture.rng); + } catch (const std::logic_error& e) { + caught_exception = true; + result.assert_true(std::string(e.what()).find("no operators") != std::string::npos, + "Exception message mentions no operators"); + } + + result.assert_true(caught_exception, "Applying with no operators throws logic_error"); +} + +// Test error handling: double application without reporting +void test_error_double_application(TestResult& result) { + UCBSelectorFixture fixture; + fixture.add_default_operators(); + + auto tour_copy1 = fixture.tsp_instance.tour; + auto tour_copy2 = fixture.tsp_instance.tour; + + // First application should succeed + fixture.selector.apply_local_search(fixture.tsp_instance.tsp, tour_copy1, fixture.rng); + + // Second application without report_fitness_improvement should throw + bool caught_exception = false; + try { + fixture.selector.apply_local_search(fixture.tsp_instance.tsp, tour_copy2, fixture.rng); + } catch (const std::logic_error& e) { + caught_exception = true; + result.assert_true(std::string(e.what()).find("report_fitness_improvement") != + std::string::npos, + "Exception message mentions report_fitness_improvement"); + } + + result.assert_true(caught_exception, "Double application without reporting throws logic_error"); +} + +// Test error handling: out-of-bounds selection +void test_error_out_of_bounds_selection(TestResult& result) { + UCBSelectorFixture fixture(3); // Specify 3 operators + fixture.selector.add_operator(LinKernighan(20, 5), "LinKernighan"); + // Only add 1 operator, but constructor expects 3 + + // The scheduler might select index 1 or 2 (which don't have operators) + // We'll try multiple times to trigger the error + bool caught_exception = false; + for (int attempt = 0; attempt < 20 && !caught_exception; ++attempt) { + try { + auto tour_copy = fixture.tsp_instance.tour; + fixture.selector.apply_local_search(fixture.tsp_instance.tsp, tour_copy, fixture.rng); + // If successful, we need to report to reset tracking flag + fixture.selector.report_fitness_improvement(0.0); + } catch (const std::out_of_range& e) { + caught_exception = true; + result.assert_true(std::string(e.what()).find("out of bounds") != std::string::npos, + "Exception message mentions out of bounds"); + } + } + + result.assert_true(caught_exception, "Out-of-bounds selection throws out_of_range"); +} + +// Test hybrid configuration with both crossover and local search +void test_hybrid_crossover_and_local_search(TestResult& result) { + std::mt19937 rng(42); + + // Create crossover selector + UCBOperatorSelector crossover_selector(2, 2.0, rng); + crossover_selector.add_operator(OrderCrossover(), "OX"); + crossover_selector.add_operator(PMXCrossover(), "PMX"); + + // Create local search selector + UCBLocalSearchSelector ls_selector(2, 2.0, rng); + ls_selector.add_operator(TwoOpt(), "TwoOpt"); + ls_selector.add_operator(Random2Opt(50), "Random2Opt"); + + TestTSPInstance test_instance; + + // Simulate a memetic algorithm generation: crossover -> local search + for (int gen = 0; gen < 5; ++gen) { + // Apply crossover to create offspring + auto [offspring1, offspring2] = crossover_selector.apply_crossover( + test_instance.tsp, test_instance.tour, test_instance.tour, rng); + + // Report crossover improvement (simplified: use fixed reward for this integration test. + // In a real memetic algorithm, this would compare offspring vs parent fitness) + crossover_selector.report_fitness_improvement(0.0); + + // Evaluate offspring before local search + auto fitness_before_ls1 = test_instance.tsp.evaluate(offspring1); + auto fitness_before_ls2 = test_instance.tsp.evaluate(offspring2); + + // Apply local search to first offspring and report improvement + auto fitness_after_ls1 = ls_selector.apply_local_search(test_instance.tsp, offspring1, rng); + ls_selector.report_fitness_improvement(fitness_before_ls1.value - fitness_after_ls1.value); + + // Apply local search to second offspring and report improvement + auto fitness_after_ls2 = ls_selector.apply_local_search(test_instance.tsp, offspring2, rng); + ls_selector.report_fitness_improvement(fitness_before_ls2.value - fitness_after_ls2.value); + } + + // Verify both selectors tracked statistics + result.assert_eq(crossover_selector.get_operator_count(), static_cast(2), + "Crossover selector has correct operator count"); + result.assert_eq(ls_selector.get_operator_count(), static_cast(2), + "Local search selector has correct operator count"); + + const auto& crossover_stats = crossover_selector.get_operator_stats(); + const auto& ls_stats = ls_selector.get_operator_stats(); + + result.assert_eq(crossover_stats.size(), static_cast(2), + "Crossover selector tracked both operators"); + result.assert_eq(ls_stats.size(), static_cast(2), + "Local search selector tracked both operators"); + + // Verify operators were actually used + size_t total_crossover_selections = 0; + size_t total_ls_selections = 0; + for (const auto& stat : crossover_stats) { + total_crossover_selections += stat.selection_count; + } + for (const auto& stat : ls_stats) { + total_ls_selections += stat.selection_count; + } + + result.assert_eq(total_crossover_selections, static_cast(5), + "Crossover selector made expected number of selections"); + result.assert_eq(total_ls_selections, static_cast(10), + "Local search selector made expected number of selections"); +} + +int main() { + std::cout << "=== EvoLab MAB Local Search Integration Tests ===\n\n"; + + TestResult result; + + test_local_search_operator_concept(result); + test_adaptive_local_search_selector_exists(result); + test_add_local_search_operators(result); + test_apply_local_search(result); + test_performance_tracking(result); + test_execution_time_tracking(result); + test_improvement_rate_tracking(result); + test_thompson_sampling_integration(result); + test_error_zero_operators_crossover(result); + test_error_zero_operators_local_search(result); + test_error_too_many_operators(result); + test_error_no_operators(result); + test_error_double_application(result); + test_error_out_of_bounds_selection(result); + test_hybrid_crossover_and_local_search(result); + + return result.summary(); +}