From b1ff7f2609a6803965833a56c8c86776001c06f5 Mon Sep 17 00:00:00 2001 From: Eric Kreutzer Date: Sun, 26 Jul 2026 08:50:59 -0600 Subject: [PATCH] feat(routing): Add native solution tracing --- README.md | 17 ++ ext/or-tools/routing.cpp | 254 +++++++++++++++++++++++++ lib/or_tools/routing_model.rb | 44 ++++- test/routing_solution_trace_test.rb | 283 ++++++++++++++++++++++++++++ 4 files changed, 596 insertions(+), 2 deletions(-) create mode 100644 test/routing_solution_trace_test.rb diff --git a/README.md b/README.md index 14c92a3..19b3c6f 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Routing - [Resource Constraints](#resource-constraints) - [Penalties and Dropping Visits](#penalties-and-dropping-visits) - [Routing Options](#routing-options) +- [Solution Tracing](#solution-tracing) Bin Packing @@ -1472,6 +1473,22 @@ routing.solve( ) ``` +### Solution Tracing + +```ruby +trace = routing.enable_solution_trace( + max_samples: 64, + sample_interval_ms: 50 +) + +solution = routing.solve_with_parameters(search_parameters) +trace.to_h +``` + +The trace records first, best, and latest solution objectives and timing, +solution and improvement counts, total objective improvement, and bounded +improvement samples. Each solve resets the trace. + ## Bin Packing ### The Knapsack Problem diff --git a/ext/or-tools/routing.cpp b/ext/or-tools/routing.cpp index 88ff5bc..8455327 100644 --- a/ext/or-tools/routing.cpp +++ b/ext/or-tools/routing.cpp @@ -1,6 +1,12 @@ +#include +#include +#include +#include +#include #include #include +#include #include #include #include @@ -22,11 +28,216 @@ using operations_research::RoutingSearchStatus; using Rice::Array; using Rice::Class; +using Rice::Hash; using Rice::Module; using Rice::Object; using Rice::String; using Rice::Symbol; +struct RoutingSolutionTraceState { + struct Observation { + int64_t elapsed_ms; + int64_t objective; + }; + + RoutingSolutionTraceState(int64_t max_samples, int64_t sample_interval_ms) + : max_samples(max_samples), sample_interval_ms(sample_interval_ms) { + samples.reserve(static_cast(max_samples)); + } + + void Prepare() { + std::lock_guard lock(mutex); + armed = true; + started = false; + first_solution.reset(); + best_solution.reset(); + latest_solution.reset(); + solution_count = 0; + improvement_count = 0; + samples.clear(); + last_improvement_bucket.reset(); + samples_truncated = false; + } + + void Start(absl::Time now) { + std::lock_guard lock(mutex); + if (armed && !started) { + started_at = now; + started = true; + } + } + + void Record(absl::Time now, int64_t objective) { + std::lock_guard lock(mutex); + if (!armed || !started) { + return; + } + + RecordLocked(now, objective); + } + + void Finish(absl::Time now, std::optional objective) { + std::lock_guard lock(mutex); + if (!armed) { + return; + } + + if (objective && (!best_solution || *objective < best_solution->objective)) { + if (!started) { + started_at = now; + started = true; + } + RecordLocked(now, *objective); + } + armed = false; + } + + Hash ToHash() const { + std::optional first; + std::optional best; + std::optional latest; + int64_t solutions; + int64_t improvements; + std::vector sample_snapshot; + bool truncated; + + { + std::lock_guard lock(mutex); + first = first_solution; + best = best_solution; + latest = latest_solution; + solutions = solution_count; + improvements = improvement_count; + sample_snapshot = samples; + truncated = samples_truncated; + } + + Hash result; + Array sample_values; + const auto set_observation = [&result]( + const char* elapsed_key, + const char* objective_key, + const std::optional& observation) { + if (observation) { + result[Symbol(elapsed_key)] = observation->elapsed_ms; + result[Symbol(objective_key)] = observation->objective; + } else { + result[Symbol(elapsed_key)] = Object(Qnil); + result[Symbol(objective_key)] = Object(Qnil); + } + }; + + for (const Observation& sample : sample_snapshot) { + Array value; + value.push(sample.elapsed_ms); + value.push(sample.objective); + sample_values.push(value); + } + + set_observation( + "first_solution_ms", + "first_solution_objective", + first); + set_observation( + "best_solution_ms", + "best_solution_objective", + best); + set_observation( + "latest_solution_ms", + "latest_solution_objective", + latest); + result[Symbol("solution_count")] = solutions; + result[Symbol("improvement_count")] = improvements; + if (first && best) { + result[Symbol("objective_improvement")] = + first->objective - best->objective; + } else { + result[Symbol("objective_improvement")] = Object(Qnil); + } + result[Symbol("samples")] = sample_values; + result[Symbol("samples_truncated")] = truncated; + return result; + } + +private: + void RecordLocked(absl::Time now, int64_t objective) { + const int64_t elapsed_ms = + std::max(0, absl::ToInt64Milliseconds(now - started_at)); + const Observation observation{elapsed_ms, objective}; + const bool first = !first_solution.has_value(); + const bool improvement = + !best_solution.has_value() || objective < best_solution->objective; + + solution_count++; + latest_solution = observation; + + if (first) { + first_solution = observation; + samples.push_back(observation); + } else if (improvement) { + improvement_count++; + } + + if (improvement) { + best_solution = observation; + + if (!first) { + const int64_t bucket = elapsed_ms / sample_interval_ms; + if (last_improvement_bucket == bucket) { + samples.back() = observation; + } else if (samples.size() < static_cast(max_samples)) { + samples.push_back(observation); + last_improvement_bucket = bucket; + } else { + samples_truncated = true; + } + } + } + } + + mutable std::mutex mutex; + const int64_t max_samples; + const int64_t sample_interval_ms; + absl::Time started_at; + bool armed = false; + bool started = false; + std::optional first_solution; + std::optional best_solution; + std::optional latest_solution; + int64_t solution_count = 0; + int64_t improvement_count = 0; + std::vector samples; + std::optional last_improvement_bucket; + bool samples_truncated = false; +}; + +class RoutingSolutionTrace { +public: + RoutingSolutionTrace(int64_t max_samples, int64_t sample_interval_ms) + : state_(std::make_shared( + max_samples, + sample_interval_ms)) { } + + std::shared_ptr state() const { + return state_; + } + + Hash ToHash() const { + return state_->ToHash(); + } + + void Prepare() { + state_->Prepare(); + } + + void Finish(absl::Time now, std::optional objective) { + state_->Finish(now, objective); + } + +private: + std::shared_ptr state_; +}; + namespace Rice::detail { template<> struct Type { @@ -100,6 +311,10 @@ void init_routing(Rice::Module& m) { auto rb_cRoutingSearchParameters = Rice::define_class_under(m, "RoutingSearchParameters"); auto rb_cIntVar = Rice::define_class_under(m, "RoutingIntVar"); + Rice::define_class_under(m, "RoutingSolutionTrace") + .define_method("to_h", &RoutingSolutionTrace::ToHash) + .define_method("_prepare", &RoutingSolutionTrace::Prepare); + m.define_singleton_function("default_routing_search_parameters", &DefaultRoutingSearchParameters); rb_cRoutingSearchParameters @@ -428,6 +643,45 @@ void init_routing(Rice::Module& m) { .define_method("add_variable_target_to_finalizer", &RoutingModel::AddVariableTargetToFinalizer) .define_method("add_weighted_variable_target_to_finalizer", &RoutingModel::AddWeightedVariableTargetToFinalizer) .define_method("close_model", &RoutingModel::CloseModel) + .define_method( + "_enable_solution_trace", + [](RoutingModel& self, int64_t max_samples, int64_t sample_interval_ms) { + if (max_samples <= 0) { + throw std::invalid_argument{"max_samples must be positive"}; + } + if (sample_interval_ms <= 0) { + throw std::invalid_argument{"sample_interval_ms must be positive"}; + } + + RoutingSolutionTrace trace(max_samples, sample_interval_ms); + std::shared_ptr state = trace.state(); + RoutingModel* model = &self; + operations_research::Solver* solver = self.solver(); + + // Use the solver clock so trace timing matches OR-Tools time limits. + self.AddEnterSearchCallback([state, solver]() { + state->Start(solver->Now()); + }); + self.AddAtSolutionCallback( + [state, model, solver]() { + operations_research::IntVar* cost = model->CostVar(); + const int64_t objective = + cost != nullptr && cost->Bound() + ? cost->Min() + : solver->GetOrCreateLocalSearchState()->ObjectiveMin(); + state->Record(solver->Now(), objective); + }, + true); + + return trace; + }) + .define_method( + "_finish_solution_trace", + [](RoutingModel& self, + RoutingSolutionTrace& trace, + std::optional objective) { + trace.Finish(self.solver()->Now(), objective); + }) // solve defined in Ruby .define_method( "_solve_with_parameters", diff --git a/lib/or_tools/routing_model.rb b/lib/or_tools/routing_model.rb index 8581bb4..d357a11 100644 --- a/lib/or_tools/routing_model.rb +++ b/lib/or_tools/routing_model.rb @@ -1,5 +1,11 @@ module ORTools + class RoutingSolutionTrace + private :_prepare + end + class RoutingModel + private :_finish_solution_trace + def solve( solution_limit: nil, time_limit: nil, @@ -22,12 +28,31 @@ def add_disjunction(indices, penalty, max_cardinality = 1, penalty_cost_behavior _add_disjunction(indices, penalty, max_cardinality, penalty_cost_behavior) end + def enable_solution_trace(max_samples: 64, sample_interval_ms: 50) + options = [max_samples, sample_interval_ms] + if @solution_trace_options + if @solution_trace_options != options + raise ArgumentError, "solution trace is already configured" + end + + return @solution_trace + end + + @solution_trace = _enable_solution_trace(max_samples, sample_interval_ms) + @solution_trace_options = options + @solution_trace + end + def solve_with_parameters(search_parameters) - _solve_with_parameters(search_parameters, !@ruby_callback) + solve_with_trace do + _solve_with_parameters(search_parameters, !@ruby_callback) + end end def solve_from_assignment_with_parameters(assignment, search_parameters) - _solve_from_assignment_with_parameters(assignment, search_parameters, !@ruby_callback) + solve_with_trace do + _solve_from_assignment_with_parameters(assignment, search_parameters, !@ruby_callback) + end end def register_unary_transit_callback(callback) @@ -39,5 +64,20 @@ def register_transit_callback(callback) @ruby_callback = true _register_transit_callback(callback) end + + private + + def solve_with_trace + return yield unless @solution_trace + + @solution_trace.__send__(:_prepare) + solution = nil + begin + solution = yield + ensure + _finish_solution_trace(@solution_trace, solution&.objective_value) + end + solution + end end end diff --git a/test/routing_solution_trace_test.rb b/test/routing_solution_trace_test.rb new file mode 100644 index 0000000..6131121 --- /dev/null +++ b/test/routing_solution_trace_test.rb @@ -0,0 +1,283 @@ +require_relative "test_helper" + +class RoutingSolutionTraceTest < Minitest::Test + EMPTY_TRACE = { + first_solution_ms: nil, + first_solution_objective: nil, + best_solution_ms: nil, + best_solution_objective: nil, + latest_solution_ms: nil, + latest_solution_objective: nil, + solution_count: 0, + improvement_count: 0, + objective_improvement: nil, + samples: [], + samples_truncated: false + }.freeze + + MATRIX = begin + random = Random.new(1234) + size = 25 + Array.new(size) do |from| + Array.new(size) do |to| + from == to ? 0 : random.rand(1..10_000) + end.freeze + end.freeze + end + + def test_records_solution_progress + routing = build_routing + trace = routing.enable_solution_trace( + max_samples: 64, + sample_interval_ms: 1 + ) + + assert_equal EMPTY_TRACE, trace.to_h + + solution = routing.solve_with_parameters(search_parameters(solution_limit: 50)) + values = trace.to_h + + assert_equal solution.objective_value, values[:best_solution_objective] + assert_operator values[:first_solution_ms], :>=, 0 + assert_operator values[:best_solution_ms], :>=, values[:first_solution_ms] + assert_operator values[:latest_solution_ms], :>=, values[:first_solution_ms] + assert_operator values[:first_solution_objective], :>=, values[:best_solution_objective] + assert_operator values[:latest_solution_objective], :>=, values[:best_solution_objective] + assert_operator values[:solution_count], :>=, values[:improvement_count] + 1 + assert_equal( + values[:first_solution_objective] - values[:best_solution_objective], + values[:objective_improvement] + ) + assert_operator values[:samples].length, :<=, 64 + refute values[:samples_truncated] + assert_equal( + [values[:first_solution_ms], values[:first_solution_objective]], + values[:samples].first + ) + assert_equal( + [values[:best_solution_ms], values[:best_solution_objective]], + values[:samples].last + ) + + values[:samples].each_cons(2) do |previous, current| + assert_operator current[0], :>=, previous[0] + assert_operator current[1], :<, previous[1] + end + end + + def test_replaces_improvements_in_the_same_bucket + routing = build_routing + trace = routing.enable_solution_trace( + max_samples: 64, + sample_interval_ms: 60_000 + ) + + solution = routing.solve_with_parameters(search_parameters(solution_limit: 50)) + values = trace.to_h + + assert_equal solution.objective_value, values[:best_solution_objective] + assert_equal( + [ + [values[:first_solution_ms], values[:first_solution_objective]], + [values[:best_solution_ms], values[:best_solution_objective]] + ], + values[:samples] + ) + end + + def test_keeps_aggregates_after_samples_are_truncated + routing = build_routing + trace = routing.enable_solution_trace( + max_samples: 1, + sample_interval_ms: 1 + ) + + solution = routing.solve_with_parameters(search_parameters(solution_limit: 500)) + values = trace.to_h + + assert_equal solution.objective_value, values[:best_solution_objective] + assert_equal 1, values[:samples].length + assert values[:samples_truncated] + assert_operator values[:improvement_count], :>, 1 + end + + def test_resets_for_warm_solves + routing = build_routing + trace = routing.enable_solution_trace( + max_samples: 64, + sample_interval_ms: 1 + ) + initial_solution = + routing.solve_with_parameters(search_parameters(solution_limit: 20)) + initial_values = trace.to_h + + solution = routing.solve_from_assignment_with_parameters( + initial_solution, + search_parameters(solution_limit: 10) + ) + values = trace.to_h + + assert_equal solution.objective_value, values[:best_solution_objective] + assert_operator values[:solution_count], :>, 1 + refute_equal initial_values[:solution_count], values[:solution_count] + assert_equal( + values[:first_solution_objective] - values[:best_solution_objective], + values[:objective_improvement] + ) + end + + def test_does_not_record_a_warm_start_when_no_solution_is_returned + routing = build_routing + initial_solution = + routing.solve_with_parameters(search_parameters(solution_limit: 10)) + trace = routing.enable_solution_trace + + solution = routing.solve_from_assignment_with_parameters( + initial_solution, + search_parameters(time_limit: 0) + ) + + assert_nil solution + assert_equal EMPTY_TRACE, trace.to_h + end + + def test_returns_an_empty_trace_when_no_solution_exists + manager = ORTools::RoutingIndexManager.new(2, 1, 0) + routing = ORTools::RoutingModel.new(manager) + transit = routing.register_transit_matrix([[0, 10], [10, 0]]) + routing.set_arc_cost_evaluator_of_all_vehicles(transit) + routing.add_dimension(transit, 0, 100, true, "Time") + routing + .mutable_dimension("Time") + .cumul_var(manager.node_to_index(1)) + .set_range(0, 0) + trace = routing.enable_solution_trace + + solution = routing.solve(first_solution_strategy: :path_cheapest_arc) + + assert_nil solution + assert_equal EMPTY_TRACE, trace.to_h + end + + def test_validates_and_reuses_configuration + routing = build_routing + + error = assert_raises(ArgumentError) do + routing.enable_solution_trace(max_samples: 0) + end + assert_equal "max_samples must be positive", error.message + + error = assert_raises(ArgumentError) do + routing.enable_solution_trace(sample_interval_ms: 0) + end + assert_equal "sample_interval_ms must be positive", error.message + + trace = routing.enable_solution_trace + assert_same trace, routing.enable_solution_trace + + error = assert_raises(ArgumentError) do + routing.enable_solution_trace(max_samples: 32) + end + assert_equal "solution trace is already configured", error.message + end + + def test_trace_outlives_the_routing_model + routing = build_routing + trace = routing.enable_solution_trace + solution = routing.solve_with_parameters(search_parameters(solution_limit: 10)) + objective = solution.objective_value + routing = nil + solution = nil + GC.start + + assert_equal objective, trace.to_h[:best_solution_objective] + end + + def test_other_model_searches_do_not_change_a_completed_trace + routing = build_routing + trace = routing.enable_solution_trace + solution = routing.solve_with_parameters(search_parameters(solution_limit: 10)) + values = trace.to_h + + assert routing.restore_assignment(solution) + assert_equal values, trace.to_h + end + + def test_cold_and_warm_solves_release_the_gvl + skip if valgrind? + + routing = build_routing + trace = routing.enable_solution_trace + + solution = assert_trace_readable_during_solve(trace) do + routing.solve_with_parameters(search_parameters(time_limit: 1)) + end + assert_equal solution.objective_value, trace.to_h[:best_solution_objective] + + warm_routing = build_routing + initial_solution = + warm_routing.solve_with_parameters(search_parameters(solution_limit: 10)) + warm_trace = warm_routing.enable_solution_trace + assert_equal EMPTY_TRACE, warm_trace.to_h + + solution = assert_trace_readable_during_solve(warm_trace) do + warm_routing.solve_from_assignment_with_parameters( + initial_solution, + search_parameters(time_limit: 1) + ) + end + assert_equal solution.objective_value, warm_trace.to_h[:best_solution_objective] + end + + private + + def build_routing + manager = ORTools::RoutingIndexManager.new(MATRIX.length, 4, 0) + routing = ORTools::RoutingModel.new(manager) + transit = routing.register_transit_matrix(MATRIX) + routing.set_arc_cost_evaluator_of_all_vehicles(transit) + routing + end + + def search_parameters(solution_limit: nil, time_limit: nil) + parameters = ORTools.default_routing_search_parameters + parameters.first_solution_strategy = :path_cheapest_arc + parameters.local_search_metaheuristic = :guided_local_search + parameters.solution_limit = solution_limit if solution_limit + parameters.time_limit = time_limit if time_limit + parameters + end + + def assert_trace_readable_during_solve(trace) + running = true + observed = false + valid = true + ready = Queue.new + reader = Thread.new do + ready << true + while running + values = trace.to_h + if values[:solution_count] > 0 + observed = true + valid &&= + values[:best_solution_objective] <= values[:first_solution_objective] && + values[:best_solution_objective] <= values[:latest_solution_objective] && + values[:samples].length <= 64 + end + Thread.pass + end + end + ready.pop + + solution = yield + running = false + reader.join + + assert observed, "expected the reader thread to run during the solve" + assert valid, "expected every concurrent trace snapshot to be consistent" + solution + ensure + running = false + reader&.join + end +end