diff --git a/cpp/src/routing/crossovers/ox_recombiner.cuh b/cpp/src/routing/crossovers/ox_recombiner.cuh index 404d450585..cefbd8df15 100644 --- a/cpp/src/routing/crossovers/ox_recombiner.cuh +++ b/cpp/src/routing/crossovers/ox_recombiner.cuh @@ -169,6 +169,14 @@ struct OX { return graphs_size + sol_arrays_size + helper_arrays_size; } + /// @brief OX recombination of two parents into A (offspring built in place). + /// In fixed_route mode (fleet_size == min_vehicles) the vehicle count + /// is preserved; otherwise the offspring route count may vary. + /// @param A first parent; on success, replaced by the recombined offspring. + /// @param B second parent (read-only donor genome). + /// @return true if a valid offspring was produced and applied to A; false if + /// recombination was rejected (e.g. mismatched parents, fixed-route + /// count violation, size/memory guards) — A is then left unchanged. bool recombine(Solution& A, Solution& B) { raft::common::nvtx::range fun_scope("ox"); @@ -204,7 +212,13 @@ struct OX { routes_number = std::min(routesA.size(), routesB.size()); const auto& dimensions_info = A.problem->dimensions_info; - if (dimensions_info.has_dimension(dim_t::VEHICLE_FIXED_COST)) { + // Optimal-routes search lets Bellman-Ford pick a variable number of routes to minimize + // vehicle fixed cost. This is incompatible with fixed_route mode (fleet_size == + // min_vehicles): the vehicle count cannot change, so searching over route counts both + // breaks the recreate_solution invariant (routes removed == routes added) and can drift + // the solution below min_vehicles. When the route count is fixed there is nothing to + // optimize over, so keep the strict fixed-route path. + if (!fixed_route && dimensions_info.has_dimension(dim_t::VEHICLE_FIXED_COST)) { routes_number = max_vehicle_increase + std::max(routesA.size(), routesB.size()); optimal_routes_search = true; } @@ -390,9 +404,13 @@ struct OX { --i; } - if (fixed_route) { - cuopt_assert(routes_to_remove.size() == tmp_routes.size(), - "number of routes removed and routes added should be same"); + if (fixed_route && routes_to_remove.size() != tmp_routes.size()) { + // In fixed_route mode the vehicle count must be preserved: we add one route per changed + // offspring segment (tmp_routes) and remove the distinct original routes those segments + // touch (routes_to_remove). A mismatch would change the vehicle count and drop it below + // min_vehicles, so reject this offspring instead of applying it. + cuopt_assert(false, "number of routes removed and routes added should be same"); + return false; } if (routes_to_remove.size() == 0 || tmp_routes.size() == 0) { return false; } diff --git a/cpp/src/routing/local_search/move_candidates/move_candidates.cuh b/cpp/src/routing/local_search/move_candidates/move_candidates.cuh index 1fb6ab1bba..d009209226 100644 --- a/cpp/src/routing/local_search/move_candidates/move_candidates.cuh +++ b/cpp/src/routing/local_search/move_candidates/move_candidates.cuh @@ -121,10 +121,10 @@ class move_path_t { } } + /// Reset per-iteration move state void reset(solution_handle_t const* sol_handle) { - constexpr i_t zero_val = 0; - n_insertions.set_value_async(zero_val, sol_handle->get_stream()); + n_insertions.set_value_to_zero_async(sol_handle->get_stream()); async_fill(loop_closed, 1, sol_handle->get_stream()); async_fill(changed_routes, 0, sol_handle->get_stream()); } diff --git a/python/cuopt/cuopt/tests/routing/test_solver.py b/python/cuopt/cuopt/tests/routing/test_solver.py index 92622b1f16..b7cca6c628 100644 --- a/python/cuopt/cuopt/tests/routing/test_solver.py +++ b/python/cuopt/cuopt/tests/routing/test_solver.py @@ -3,6 +3,7 @@ import os import numpy as np +import pytest import cudf @@ -262,3 +263,74 @@ def test_prize_collection(): assert objectives[routing.Objective.COST] == 13.0 assert sol.get_status() == 0 assert sol.get_vehicle_count() >= 2 + + +# Cost matrix from issue #904 (7 locations: depot 0 + orders 1-6) +_ISSUE_904_COST_MATRIX = [ + [0, 17, 12, 11, 10, 18, 10], + [16, 0, 15, 11, 19, 15, 16], + [19, 19, 0, 11, 16, 11, 17], + [17, 19, 17, 0, 11, 18, 19], + [10, 19, 19, 19, 0, 17, 15], + [12, 18, 15, 18, 18, 0, 14], + [12, 12, 11, 19, 10, 17, 0], +] + + +def _build_min_vehicles_data_model(vehicle_fixed_costs, min_vehicles=3): + """Builds min vehicles regression data model""" + n_locations = 7 + n_vehicles = 3 + n_orders = 6 + dm = routing.DataModel(n_locations, n_vehicles, n_orders) + dm.add_cost_matrix( + cudf.DataFrame(_ISSUE_904_COST_MATRIX).astype(np.float32) + ) + # Capacity 10 lets all 6 orders fit on one vehicle; min_vehicles must still + # force 3 routes. + dm.add_capacity_dimension( + "demand", + cudf.Series([1] * n_orders, dtype=np.int32), + cudf.Series([10] * n_vehicles, dtype=np.int32), + ) + dm.set_order_locations(cudf.Series([1, 2, 3, 4, 5, 6], dtype=np.int32)) + dm.set_vehicle_fixed_costs( + cudf.Series(vehicle_fixed_costs, dtype=np.float32) + ) + dm.set_min_vehicles(min_vehicles) + dm.set_objective_function( + cudf.Series( + [routing.Objective.COST, routing.Objective.VEHICLE_FIXED_COST] + ), + cudf.Series([1.0, 1.0], dtype=np.float32), + ) + return dm + + +@pytest.mark.parametrize( + "vehicle_fixed_costs", + [ + [10.0, 20.0, 30.0], # non-zero fixed costs (the bug case in #904) + [ + 0.0, + 0.0, + 0.0, + ], # zero fixed costs (H100 12.2/13.1 compat crashed in debug) + ], +) +def test_min_vehicles_respected(vehicle_fixed_costs): + """ + Regression for https://github.com/NVIDIA/cuopt/issues/904. + Verifies that min_vehicles is respected and no crash occurs, regardless of + vehicle fixed costs. + """ + dm = _build_min_vehicles_data_model( + vehicle_fixed_costs=vehicle_fixed_costs + ) + ss = routing.SolverSettings() + ss.set_time_limit(3) + + sol = routing.Solve(dm, ss) + + assert sol.get_status() == 0, sol.get_message() + assert sol.get_vehicle_count() >= 3